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