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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user