From 9248ae62c760f86477702e232030257c4772b66b Mon Sep 17 00:00:00 2001 From: agent Date: Mon, 20 Jul 2026 04:52:01 +0000 Subject: [PATCH] Fix: Mobile-Graph drag/zoom/navigate via touch-action + touch-event fallbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnose: Mobile Graph sichtbar aber nicht bedienbar. Root cause: iOS Safari interpretiert Single-Finger-Touch als Scroll-Geste statt als Drag, weil touch-action: none fehlt. Fixes: 1. CSS: touch-action: none auf .graph-modal-canvas, canvas und .graph-modal (verhindert iOS-Safari Scroll-Hijacking + Rubber-Band-Effect) 2. JS: Touch-Event-Fallbacks (touchstart/move/end/cancel) parallel zu pointer-events (für ältere iOS-Safari-Versionen die pointer-events noch nicht voll unterstützen) 3. JS: Pinch-to-Zoom via Zwei-Finger-Gestenerkennung 4. JS: Initial-Render mit Resize-Retry bis Canvas echte Dimensionen hat (Modal-Container kann initial 0x0 sein bevor Flex-Layout settled) 5. JS: ResizeObserver auf Canvas re-rendert bei Größenänderung 6. CSS: overscroll-behavior: contain auf Modal (kein Bounce) 7. CSS: -webkit-user-select: none + tap-highlight: transparent (verhindert iOS-Safari Selektions-Overlay beim Drag) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app.js | 209 ++++++++++++++++++++++++++++++++++++++++++------------ style.css | 30 ++++++++ 2 files changed, 192 insertions(+), 47 deletions(-) diff --git a/app.js b/app.js index e77fb1f..ce215d7 100644 --- a/app.js +++ b/app.js @@ -289,22 +289,32 @@ ctx.renderer.render(ctx.scene, ctx.camera); } - // Manual rotation: drag to rotate - let isPointerDown = false; + // Manual rotation: drag to rotate (pointer + touch fallback for older iOS) + let isDragging = false; let lastX = 0, lastY = 0; - function onPointerDown(e) { - isPointerDown = true; - ctx.isDragging = false; - const p = pointerXY(e, canvas); - lastX = p.x; lastY = p.y; + let dragStartTime = 0; + let dragStartXY = { x: 0, y: 0 }; + let moved = false; + + function getXY(e, canvas) { + let clientX, clientY; + if (e.touches && e.touches.length > 0) { + clientX = e.touches[0].clientX; + clientY = e.touches[0].clientY; + } else if (e.changedTouches && e.changedTouches.length > 0) { + clientX = e.changedTouches[0].clientX; + clientY = e.changedTouches[0].clientY; + } else { + clientX = e.clientX; + clientY = e.clientY; + } + const rect = canvas.getBoundingClientRect(); + const x = ((clientX - rect.left) / rect.width) * 2 - 1; + const y = -((clientY - rect.top) / rect.height) * 2 + 1; + return { x, y, clientX, clientY }; } - function onPointerMove(e) { - if (!isPointerDown) return; - const p = pointerXY(e, canvas); - const dx = p.x - lastX; - const dy = p.y - lastY; - if (Math.abs(dx) + Math.abs(dy) > 4) ctx.isDragging = true; - // Rotate camera around origin + + function rotateCamera(dx, dy) { const rotSpeed = 0.005; const offset = new THREE.Vector3().subVectors(ctx.camera.position, ctx.cameraTarget); const spherical = new THREE.Spherical().setFromVector3(offset); @@ -314,15 +324,44 @@ offset.setFromSpherical(spherical); ctx.camera.position.copy(ctx.cameraTarget).add(offset); ctx.camera.lookAt(ctx.cameraTarget); - lastX = p.x; lastY = p.y; + } + + function onDown(e) { + e.preventDefault(); + const p = getXY(e, canvas); + isDragging = true; + moved = false; + dragStartTime = Date.now(); + dragStartXY = { x: p.clientX, y: p.clientY }; + lastX = p.clientX; + lastY = p.clientY; + if (canvas.setPointerCapture && e.pointerId !== undefined) { + try { canvas.setPointerCapture(e.pointerId); } catch {} + } + } + + function onMove(e) { + if (!isDragging) return; + e.preventDefault(); + const p = getXY(e, canvas); + const dx = p.clientX - lastX; + const dy = p.clientY - lastY; + if (Math.abs(p.clientX - dragStartXY.x) + Math.abs(p.clientY - dragStartXY.y) > 8) { + moved = true; + } + rotateCamera(dx, dy); + lastX = p.clientX; + lastY = p.clientY; render(); } - function onPointerUp(e) { - if (!isPointerDown) return; - isPointerDown = false; - if (!ctx.isDragging) { - // Click — raycast for node - const p = pointerXY(e, canvas); + + function onUp(e) { + if (!isDragging) return; + isDragging = false; + const elapsed = Date.now() - dragStartTime; + // Tap = quick release without much movement + if (!moved && elapsed < 500) { + const p = getXY(e.changedTouches ? { changedTouches: e.changedTouches } : e, canvas); const raycaster = new THREE.Raycaster(); const mouse = new THREE.Vector2(p.x, p.y); raycaster.setFromCamera(mouse, ctx.camera); @@ -331,58 +370,134 @@ if (intersects.length > 0) { const nodeId = intersects[0].object.userData.nodeId; if (nodeId) { - // Navigate - const slug = nodeId; const modal = document.querySelector('.graph-modal'); if (modal) modal.classList.remove('open'); - window.location.href = '/' + slug + '.html'; + window.location.href = '/' + nodeId + '.html'; + return; } } } } - function pointerXY(e, canvas) { - const rect = canvas.getBoundingClientRect(); - const x = ((e.clientX - rect.left) / rect.width) * 2 - 1; - const y = -((e.clientY - rect.top) / rect.height) * 2 + 1; - return { x, y }; + + // Pointer events (modern: iOS 13+, all modern browsers) + canvas.addEventListener('pointerdown', onDown); + canvas.addEventListener('pointermove', onMove); + canvas.addEventListener('pointerup', onUp); + canvas.addEventListener('pointercancel', onUp); + canvas.addEventListener('pointerleave', onUp); + + // Touch events fallback (older iOS Safari 12-) + canvas.addEventListener('touchstart', onDown, { passive: false }); + canvas.addEventListener('touchmove', onMove, { passive: false }); + canvas.addEventListener('touchend', onUp, { passive: false }); + canvas.addEventListener('touchcancel', onUp, { passive: false }); + + // Mouse events fallback (older desktop browsers) + canvas.addEventListener('mousedown', onDown); + canvas.addEventListener('mousemove', onMove); + canvas.addEventListener('mouseup', onUp); + canvas.addEventListener('mouseleave', onUp); + + // Zoom with wheel (desktop) + pinch (mobile) + let pinchStartDist = null; + function getPinchDist(e) { + if (e.touches && e.touches.length >= 2) { + const dx = e.touches[0].clientX - e.touches[1].clientX; + const dy = e.touches[0].clientY - e.touches[1].clientY; + return Math.sqrt(dx*dx + dy*dy); + } + return null; } - canvas.addEventListener('pointerdown', onPointerDown); - canvas.addEventListener('pointermove', onPointerMove); - canvas.addEventListener('pointerup', onPointerUp); - canvas.addEventListener('pointercancel', onPointerUp); - - // Zoom with wheel - canvas.addEventListener('wheel', (e) => { + function onWheel(e) { e.preventDefault(); const dir = e.deltaY > 0 ? 1.1 : 0.9; ctx.camera.position.multiplyScalar(dir); ctx.camera.lookAt(ctx.cameraTarget); render(); - }, { passive: false }); + } + canvas.addEventListener('wheel', onWheel, { passive: false }); - // Resize handling - function onResize() { - const w2 = canvas.clientWidth; - const h2 = canvas.clientHeight; + function onTouchMoveForPinch(e) { + const dist = getPinchDist(e); + if (dist !== null) { + e.preventDefault(); + if (pinchStartDist === null) { + pinchStartDist = dist; + } else { + const ratio = pinchStartDist / dist; + if (Math.abs(ratio - 1) > 0.01) { + ctx.camera.position.multiplyScalar(1 / ratio); + ctx.camera.lookAt(ctx.cameraTarget); + pinchStartDist = dist; + render(); + } + } + } + } + function onTouchEndForPinch(e) { + if (e.touches && e.touches.length < 2) { + pinchStartDist = null; + } + } + canvas.addEventListener('touchmove', onTouchMoveForPinch, { passive: false }); + canvas.addEventListener('touchend', onTouchEndForPinch, { passive: false }); + + // Resize handling — wait for actual visible dimensions + function safeResize() { + const w2 = canvas.clientWidth || canvas.parentElement.clientWidth; + const h2 = canvas.clientHeight || canvas.parentElement.clientHeight; if (w2 === 0 || h2 === 0) return; ctx.camera.aspect = w2 / h2; ctx.camera.updateProjectionMatrix(); ctx.renderer.setSize(w2, h2, false); render(); } + + function onResize() { + safeResize(); + } window.addEventListener('resize', onResize); - // First render - render(); + // Force-initial render with retry until canvas has dimensions + let resizeRetries = 0; + function tryInitialRender() { + safeResize(); + if ((canvas.clientWidth === 0 || canvas.clientHeight === 0) && resizeRetries < 10) { + resizeRetries++; + requestAnimationFrame(tryInitialRender); + } + } + requestAnimationFrame(tryInitialRender); + // Listen for modal-open to re-check sizing + if (container.classList.contains('graph-modal')) { + const observer = new ResizeObserver(() => safeResize()); + observer.observe(canvas); + ctx.cleanupObserver = () => observer.disconnect(); + } + + // Cleanup function ctx.render = render; ctx.cleanup = () => { - canvas.removeEventListener('pointerdown', onPointerDown); - canvas.removeEventListener('pointermove', onPointerMove); - canvas.removeEventListener('pointerup', onPointerUp); - canvas.removeEventListener('pointercancel', onPointerUp); + canvas.removeEventListener('pointerdown', onDown); + canvas.removeEventListener('pointermove', onMove); + canvas.removeEventListener('pointerup', onUp); + canvas.removeEventListener('pointercancel', onUp); + canvas.removeEventListener('pointerleave', onUp); + canvas.removeEventListener('touchstart', onDown); + canvas.removeEventListener('touchmove', onMove); + canvas.removeEventListener('touchend', onUp); + canvas.removeEventListener('touchcancel', onUp); + canvas.removeEventListener('mousedown', onDown); + canvas.removeEventListener('mousemove', onMove); + canvas.removeEventListener('mouseup', onUp); + canvas.removeEventListener('mouseleave', onUp); + canvas.removeEventListener('wheel', onWheel); + canvas.removeEventListener('touchmove', onTouchMoveForPinch); + canvas.removeEventListener('touchend', onTouchEndForPinch); window.removeEventListener('resize', onResize); + if (ctx.cleanupObserver) ctx.cleanupObserver(); }; return ctx; } diff --git a/style.css b/style.css index 8797ea9..cac7341 100644 --- a/style.css +++ b/style.css @@ -514,15 +514,45 @@ body { flex: 1; position: relative; overflow: hidden; + touch-action: none; /* critical: prevent iOS Safari scroll-hijacking */ + -webkit-user-select: none; + user-select: none; + -webkit-tap-highlight-color: transparent; + min-height: 0; /* flex-child needs explicit min-height for sizing */ + width: 100%; + height: 100%; } .graph-modal-canvas canvas { display: block; width: 100% !important; height: 100% !important; cursor: grab; + touch-action: none; /* critical: same on canvas itself */ + -webkit-user-select: none; + user-select: none; } .graph-modal-canvas canvas:active { cursor: grabbing; } +/* Modal body must not scroll on background */ +.graph-modal { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--bg-primary); + z-index: 200; + flex-direction: column; + padding-top: var(--sat); + padding-left: var(--sal); + padding-right: var(--sar); + padding-bottom: var(--sab); + touch-action: none; + overscroll-behavior: contain; +} +.graph-modal.open { display: flex; } + /* Graph legend (small floating panel) */ .graph-legend { position: absolute;