[claude] Add photo mode with camera controls and depth of field (#134) #177

Merged
claude merged 1 commits from claude/issue-134 into main 2026-03-24 04:06:45 +00:00
3 changed files with 125 additions and 3 deletions

79
app.js
View File

@@ -1,4 +1,8 @@
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { BokehPass } from 'three/addons/postprocessing/BokehPass.js';
// === COLOR PALETTE ===
const NEXUS = {
@@ -144,11 +148,75 @@ document.addEventListener('keydown', (e) => {
}
});
// === PHOTO MODE ===
let photoMode = false;
// Post-processing composer for depth of field
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bokehPass = new BokehPass(scene, camera, {
focus: 5.0,
aperture: 0.0003,
maxblur: 0.008,
});
bokehPass.enabled = false;
composer.addPass(bokehPass);
// Orbit controls for free camera movement in photo mode
const orbitControls = new OrbitControls(camera, renderer.domElement);
orbitControls.enableDamping = true;
orbitControls.dampingFactor = 0.05;
orbitControls.enabled = false;
const photoIndicator = document.getElementById('photo-indicator');
const photoFocusDisplay = document.getElementById('photo-focus');
/**
* Updates the photo mode focus distance display.
*/
function updateFocusDisplay() {
if (photoFocusDisplay) {
photoFocusDisplay.textContent = bokehPass.uniforms['focus'].value.toFixed(1);
}
}
document.addEventListener('keydown', (e) => {
if (e.key === 'p' || e.key === 'P') {
photoMode = !photoMode;
document.body.classList.toggle('photo-mode', photoMode);
bokehPass.enabled = photoMode;
orbitControls.enabled = photoMode;
if (photoIndicator) {
photoIndicator.classList.toggle('visible', photoMode);
}
if (photoMode) {
// Sync orbit target to current look-at
orbitControls.target.set(0, 0, 0);
orbitControls.update();
updateFocusDisplay();
}
}
// Adjust focus with [ ] while in photo mode
if (photoMode) {
const focusStep = 0.5;
if (e.key === '[') {
bokehPass.uniforms['focus'].value = Math.max(0.5, bokehPass.uniforms['focus'].value - focusStep);
updateFocusDisplay();
} else if (e.key === ']') {
bokehPass.uniforms['focus'].value = Math.min(200, bokehPass.uniforms['focus'].value + focusStep);
updateFocusDisplay();
}
}
});
// === RESIZE HANDLER ===
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
});
// === ANIMATION LOOP ===
@@ -168,8 +236,8 @@ function animate() {
camera.position.lerpVectors(NORMAL_CAM, OVERVIEW_CAM, overviewT);
camera.lookAt(0, 0, 0);
// Slow auto-rotation — suppressed during overview so the map stays readable
const rotationScale = 1 - overviewT;
// Slow auto-rotation — suppressed during overview and photo mode
const rotationScale = photoMode ? 0 : (1 - overviewT);
targetRotX += (mouseY * 0.3 - targetRotX) * 0.02;
targetRotY += (mouseX * 0.3 - targetRotY) * 0.02;
@@ -182,7 +250,12 @@ function animate() {
// Subtle pulse on constellation opacity
constellationLines.material.opacity = 0.12 + Math.sin(elapsed * 0.5) * 0.06;
renderer.render(scene, camera);
if (photoMode) {
orbitControls.update();
composer.render();
} else {
renderer.render(scene, camera);
}
}
animate();

View File

@@ -41,6 +41,11 @@
<span class="overview-hint">[Tab] to exit</span>
</div>
<div id="photo-indicator">
<span>PHOTO MODE</span>
<span class="photo-hint">[P] exit &nbsp;|&nbsp; [[] focus- &nbsp; []] focus+ &nbsp; focus: <span id="photo-focus">5.0</span></span>
</div>
<script type="module" src="app.js"></script>
</body>
</html>

View File

@@ -106,3 +106,47 @@ canvas {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
/* === PHOTO MODE === */
body.photo-mode .hud-controls {
display: none;
}
body.photo-mode #overview-indicator {
display: none !important;
}
#photo-indicator {
display: none;
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
color: var(--color-primary);
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-primary);
padding: 4px 12px;
background: rgba(0, 0, 8, 0.5);
white-space: nowrap;
animation: overview-pulse 2s ease-in-out infinite;
}
#photo-indicator.visible {
display: block;
}
.photo-hint {
margin-left: 12px;
color: var(--color-text-muted);
font-size: 10px;
letter-spacing: 0.1em;
}
#photo-focus {
color: var(--color-primary);
}