Fix: Mobile-Graph drag/zoom/navigate via touch-action + touch-event fallbacks

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 04:52:01 +00:00
co-authored by Claude
parent d0e4e8ab9e
commit 9248ae62c7
2 changed files with 192 additions and 47 deletions
+162 -47
View File
@@ -289,22 +289,32 @@
ctx.renderer.render(ctx.scene, ctx.camera); ctx.renderer.render(ctx.scene, ctx.camera);
} }
// Manual rotation: drag to rotate // Manual rotation: drag to rotate (pointer + touch fallback for older iOS)
let isPointerDown = false; let isDragging = false;
let lastX = 0, lastY = 0; let lastX = 0, lastY = 0;
function onPointerDown(e) { let dragStartTime = 0;
isPointerDown = true; let dragStartXY = { x: 0, y: 0 };
ctx.isDragging = false; let moved = false;
const p = pointerXY(e, canvas);
lastX = p.x; lastY = p.y; 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; function rotateCamera(dx, dy) {
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
const rotSpeed = 0.005; const rotSpeed = 0.005;
const offset = new THREE.Vector3().subVectors(ctx.camera.position, ctx.cameraTarget); const offset = new THREE.Vector3().subVectors(ctx.camera.position, ctx.cameraTarget);
const spherical = new THREE.Spherical().setFromVector3(offset); const spherical = new THREE.Spherical().setFromVector3(offset);
@@ -314,15 +324,44 @@
offset.setFromSpherical(spherical); offset.setFromSpherical(spherical);
ctx.camera.position.copy(ctx.cameraTarget).add(offset); ctx.camera.position.copy(ctx.cameraTarget).add(offset);
ctx.camera.lookAt(ctx.cameraTarget); 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(); render();
} }
function onPointerUp(e) {
if (!isPointerDown) return; function onUp(e) {
isPointerDown = false; if (!isDragging) return;
if (!ctx.isDragging) { isDragging = false;
// Click — raycast for node const elapsed = Date.now() - dragStartTime;
const p = pointerXY(e, canvas); // 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 raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2(p.x, p.y); const mouse = new THREE.Vector2(p.x, p.y);
raycaster.setFromCamera(mouse, ctx.camera); raycaster.setFromCamera(mouse, ctx.camera);
@@ -331,58 +370,134 @@
if (intersects.length > 0) { if (intersects.length > 0) {
const nodeId = intersects[0].object.userData.nodeId; const nodeId = intersects[0].object.userData.nodeId;
if (nodeId) { if (nodeId) {
// Navigate
const slug = nodeId;
const modal = document.querySelector('.graph-modal'); const modal = document.querySelector('.graph-modal');
if (modal) modal.classList.remove('open'); 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(); // Pointer events (modern: iOS 13+, all modern browsers)
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1; canvas.addEventListener('pointerdown', onDown);
const y = -((e.clientY - rect.top) / rect.height) * 2 + 1; canvas.addEventListener('pointermove', onMove);
return { x, y }; 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); function onWheel(e) {
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointercancel', onPointerUp);
// Zoom with wheel
canvas.addEventListener('wheel', (e) => {
e.preventDefault(); e.preventDefault();
const dir = e.deltaY > 0 ? 1.1 : 0.9; const dir = e.deltaY > 0 ? 1.1 : 0.9;
ctx.camera.position.multiplyScalar(dir); ctx.camera.position.multiplyScalar(dir);
ctx.camera.lookAt(ctx.cameraTarget); ctx.camera.lookAt(ctx.cameraTarget);
render(); render();
}, { passive: false }); }
canvas.addEventListener('wheel', onWheel, { passive: false });
// Resize handling function onTouchMoveForPinch(e) {
function onResize() { const dist = getPinchDist(e);
const w2 = canvas.clientWidth; if (dist !== null) {
const h2 = canvas.clientHeight; 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; if (w2 === 0 || h2 === 0) return;
ctx.camera.aspect = w2 / h2; ctx.camera.aspect = w2 / h2;
ctx.camera.updateProjectionMatrix(); ctx.camera.updateProjectionMatrix();
ctx.renderer.setSize(w2, h2, false); ctx.renderer.setSize(w2, h2, false);
render(); render();
} }
function onResize() {
safeResize();
}
window.addEventListener('resize', onResize); window.addEventListener('resize', onResize);
// First render // Force-initial render with retry until canvas has dimensions
render(); 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.render = render;
ctx.cleanup = () => { ctx.cleanup = () => {
canvas.removeEventListener('pointerdown', onPointerDown); canvas.removeEventListener('pointerdown', onDown);
canvas.removeEventListener('pointermove', onPointerMove); canvas.removeEventListener('pointermove', onMove);
canvas.removeEventListener('pointerup', onPointerUp); canvas.removeEventListener('pointerup', onUp);
canvas.removeEventListener('pointercancel', onPointerUp); 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); window.removeEventListener('resize', onResize);
if (ctx.cleanupObserver) ctx.cleanupObserver();
}; };
return ctx; return ctx;
} }
+30
View File
@@ -514,15 +514,45 @@ body {
flex: 1; flex: 1;
position: relative; position: relative;
overflow: hidden; 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 { .graph-modal-canvas canvas {
display: block; display: block;
width: 100% !important; width: 100% !important;
height: 100% !important; height: 100% !important;
cursor: grab; cursor: grab;
touch-action: none; /* critical: same on canvas itself */
-webkit-user-select: none;
user-select: none;
} }
.graph-modal-canvas canvas:active { cursor: grabbing; } .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 (small floating panel) */
.graph-legend { .graph-legend {
position: absolute; position: absolute;