[claude] Add zoom-to-object on double-click (#139) #235

Merged
Timmy merged 1 commits from claude/issue-139 into main 2026-03-24 04:54:42 +00:00
3 changed files with 118 additions and 2 deletions

84
app.js
View File

@@ -231,6 +231,9 @@ voidLight.position.set(0, -3.5, 0);
glassPlatformGroup.add(voidLight);
scene.add(glassPlatformGroup);
glassPlatformGroup.traverse(obj => {
if (obj.isMesh) obj.userData.zoomLabel = 'Glass Platform';
});
// === COMMIT HEATMAP ===
// Canvas-texture overlay on the floor. Each agent occupies a polar sector;
@@ -269,6 +272,7 @@ const heatmapMesh = new THREE.Mesh(
);
heatmapMesh.rotation.x = -Math.PI / 2;
heatmapMesh.position.y = 0.005;
heatmapMesh.userData.zoomLabel = 'Activity Heatmap';
scene.add(heatmapMesh);
// Per-zone intensity [0..1], updated by updateHeatmap()
@@ -408,6 +412,66 @@ document.addEventListener('keydown', (e) => {
}
});
// === ZOOM-TO-OBJECT ===
const _zoomRaycaster = new THREE.Raycaster();
const _zoomMouse = new THREE.Vector2();
const _zoomCamTarget = new THREE.Vector3();
const _zoomLookTarget = new THREE.Vector3();
let zoomT = 0;
let zoomTargetT = 0;
let zoomActive = false;
const zoomIndicator = document.getElementById('zoom-indicator');
const zoomLabelEl = document.getElementById('zoom-label');
function getZoomLabel(/** @type {THREE.Object3D} */ obj) {
let o = /** @type {THREE.Object3D|null} */ (obj);
while (o) {
if (o.userData && o.userData.zoomLabel) return o.userData.zoomLabel;
o = o.parent;
}
return 'Object';
}
function exitZoom() {
zoomTargetT = 0;
zoomActive = false;
if (zoomIndicator) zoomIndicator.classList.remove('visible');
}
renderer.domElement.addEventListener('dblclick', (/** @type {MouseEvent} */ e) => {
if (overviewMode || photoMode) return;
_zoomMouse.x = (e.clientX / window.innerWidth) * 2 - 1;
_zoomMouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
_zoomRaycaster.setFromCamera(_zoomMouse, camera);
const hits = _zoomRaycaster.intersectObjects(scene.children, true)
.filter(h => !(h.object instanceof THREE.Points) && !(h.object instanceof THREE.Line));
if (!hits.length) {
exitZoom();
return;
}
const hit = hits[0];
const label = getZoomLabel(hit.object);
const dir = new THREE.Vector3().subVectors(camera.position, hit.point).normalize();
const flyDist = Math.max(1.5, Math.min(5, hit.distance * 0.45));
_zoomCamTarget.copy(hit.point).addScaledVector(dir, flyDist);
_zoomLookTarget.copy(hit.point);
zoomT = 0;
zoomTargetT = 1;
zoomActive = true;
if (zoomLabelEl) zoomLabelEl.textContent = label;
if (zoomIndicator) zoomIndicator.classList.add('visible');
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') exitZoom();
});
// === PHOTO MODE ===
let photoMode = false;
@@ -550,6 +614,9 @@ meterSprite.scale.set(3.2, 1.6, 1);
sovereigntyGroup.add(meterSprite);
scene.add(sovereigntyGroup);
sovereigntyGroup.traverse(obj => {
if (obj.isMesh || obj.isSprite) obj.userData.zoomLabel = 'Sovereignty Meter';
});
async function loadSovereigntyStatus() {
try {
@@ -590,8 +657,19 @@ function animate() {
// Smooth camera transition for overview mode
const targetT = overviewMode ? 1 : 0;
overviewT += (targetT - overviewT) * 0.04;
camera.position.lerpVectors(NORMAL_CAM, OVERVIEW_CAM, overviewT);
camera.lookAt(0, 0, 0);
const _basePos = new THREE.Vector3().lerpVectors(NORMAL_CAM, OVERVIEW_CAM, overviewT);
// Zoom-to-object interpolation
if (!photoMode) {
zoomT += (zoomTargetT - zoomT) * 0.07;
}
if (zoomT > 0.001 && !photoMode && !overviewMode) {
camera.position.lerpVectors(_basePos, _zoomCamTarget, zoomT);
camera.lookAt(new THREE.Vector3(0, 0, 0).lerp(_zoomLookTarget, zoomT));
} else {
camera.position.copy(_basePos);
camera.lookAt(0, 0, 0);
}
// Slow auto-rotation — suppressed during overview and photo mode
const rotationScale = photoMode ? 0 : (1 - overviewT);
@@ -931,6 +1009,7 @@ async function initCommitBanners() {
startDelay: i * 2.5,
lifetime: 12 + i * 1.5,
spawnTime: /** @type {number|null} */ (null),
zoomLabel: `Commit: ${commit.hash}`,
};
scene.add(sprite);
commitBanners.push(sprite);
@@ -1076,6 +1155,7 @@ function rebuildAgentPanels(statusData) {
baseY: BOARD_Y,
floatPhase: (i / n) * Math.PI * 2,
floatSpeed: 0.18 + i * 0.04,
zoomLabel: `Agent: ${agent.name}`,
};
agentBoardGroup.add(sprite);
agentPanelSprites.push(sprite);

View File

@@ -48,6 +48,11 @@
<div id="sovereignty-msg">⚡ SOVEREIGNTY ⚡</div>
<div id="zoom-indicator">
<span>ZOOMED: <span id="zoom-label">Object</span></span>
<span class="zoom-hint">[Esc] or double-click to exit</span>
</div>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});

View File

@@ -151,6 +151,37 @@ body.photo-mode #overview-indicator {
color: var(--color-primary);
}
/* === ZOOM-TO-OBJECT INDICATOR === */
#zoom-indicator {
display: none;
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
color: var(--color-accent);
font-family: var(--font-body);
font-size: 11px;
letter-spacing: 0.2em;
text-transform: uppercase;
pointer-events: none;
z-index: 20;
border: 1px solid var(--color-accent);
padding: 4px 12px;
background: rgba(0, 0, 8, 0.6);
white-space: nowrap;
animation: overview-pulse 2s ease-in-out infinite;
}
#zoom-indicator.visible {
display: block;
}
.zoom-hint {
margin-left: 12px;
color: var(--color-text-muted);
font-size: 10px;
}
/* === SOVEREIGNTY EASTER EGG === */
#sovereignty-msg {
display: none;