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);
}
// 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;
}
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
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 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 };
}
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointercancel', onPointerUp);
// 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);
// Zoom with wheel
canvas.addEventListener('wheel', (e) => {
// 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;
}
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;
}
+30
View File
@@ -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;