Files
the-matrix/js/interaction.js
Alexander Whitestone d5f407b892 feat: add mobile-responsive touch controls (#1)
- Configure OrbitControls touch mapping explicitly (one-finger orbit,
  two-finger pinch-zoom + pan) for iOS Safari and Android Chrome
- Add tap-to-interact via Raycaster: tapping an agent fires a
  visitor_interaction event and shows a brief floating label
- Add touch hint overlay (DRAG · PINCH TO ZOOM · TAP AGENT) that
  appears on first touch and fades after 3.5 s
- Expose getAgentMeshes() from agents.js for raycasting targets
- Clean up disposeInteraction() to remove pointer listeners

Fixes #1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 14:08:58 -04:00

109 lines
3.1 KiB
JavaScript

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { getAgentMeshes } from './agents.js';
import { sendVisitorInteraction } from './visitor.js';
let controls;
let _camera, _renderer;
const raycaster = new THREE.Raycaster();
const _tapStart = { x: 0, y: 0 };
const TAP_MAX_MOVE = 10; // px — movement above this = drag, not tap
let _touchHintShown = false;
export function initInteraction(camera, renderer) {
_camera = camera;
_renderer = renderer;
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.screenSpacePanning = false;
controls.minDistance = 5;
controls.maxDistance = 80;
controls.maxPolarAngle = Math.PI / 2.1;
controls.target.set(0, 0, 0);
// Explicit touch mapping: one finger = orbit, two fingers = pinch-zoom + pan
controls.touches = {
ONE: THREE.TOUCH.ROTATE,
TWO: THREE.TOUCH.DOLLY_PAN,
};
controls.update();
renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());
renderer.domElement.addEventListener('pointerdown', _onPointerDown);
renderer.domElement.addEventListener('pointerup', _onPointerUp);
}
function _onPointerDown(e) {
_tapStart.x = e.clientX;
_tapStart.y = e.clientY;
// Show touch hint on first touch interaction
if (!_touchHintShown && e.pointerType === 'touch') {
_touchHintShown = true;
_showTouchHint();
}
}
function _onPointerUp(e) {
const dx = e.clientX - _tapStart.x;
const dy = e.clientY - _tapStart.y;
if (Math.sqrt(dx * dx + dy * dy) > TAP_MAX_MOVE) return; // drag, not tap
const rect = _renderer.domElement.getBoundingClientRect();
const ndc = new THREE.Vector2(
((e.clientX - rect.left) / rect.width) * 2 - 1,
-((e.clientY - rect.top) / rect.height) * 2 + 1,
);
raycaster.setFromCamera(ndc, _camera);
const meshes = getAgentMeshes();
const hits = raycaster.intersectObjects(meshes.map(m => m.mesh));
if (hits.length > 0) {
const hit = meshes.find(m => m.mesh === hits[0].object);
if (hit) {
sendVisitorInteraction(hit.id, 'tap');
_showTapFeedback(hit.label, e.clientX, e.clientY);
}
}
}
function _showTapFeedback(label, x, y) {
const el = document.createElement('div');
el.className = 'tap-feedback';
el.textContent = `[ ${label} ]`;
el.style.left = x + 'px';
el.style.top = (y - 40) + 'px';
document.body.appendChild(el);
setTimeout(() => el.remove(), 1200);
}
function _showTouchHint() {
const hint = document.getElementById('touch-hint');
if (!hint) return;
hint.classList.add('visible');
setTimeout(() => hint.classList.remove('visible'), 3500);
}
export function updateControls() {
if (controls) controls.update();
}
/**
* Dispose orbit controls and interaction listeners (used on world teardown).
*/
export function disposeInteraction() {
if (_renderer) {
_renderer.domElement.removeEventListener('pointerdown', _onPointerDown);
_renderer.domElement.removeEventListener('pointerup', _onPointerUp);
}
if (controls) {
controls.dispose();
controls = null;
}
_camera = null;
_renderer = null;
}