// ═══════════════════════════════════════════════════════ // PROJECT MNEMOSYNE — SESSION ROOMS (Issue #1171) // ═══════════════════════════════════════════════════════ // // Groups memories by session into holographic chambers. // Each session becomes a wireframe cube floating in space. // Rooms are arranged chronologically along a spiral. // Click a room to fly inside; distant rooms LOD to a point. // // Usage from app.js: // SessionRooms.init(scene, camera, controls); // SessionRooms.updateSessions(sessions); // [{id, timestamp, facts[]}] // SessionRooms.update(delta); // call each frame // SessionRooms.getClickableMeshes(); // for raycasting // SessionRooms.handleRoomClick(mesh); // trigger fly-in // ═══════════════════════════════════════════════════════ const SessionRooms = (() => { // ─── CONSTANTS ─────────────────────────────────────── const MAX_ROOMS = 20; const ROOM_SIZE = 9; // wireframe cube edge length const ROOM_HALF = ROOM_SIZE / 2; const LOD_THRESHOLD = 55; // distance: full → point const LOD_HYSTERESIS = 5; // buffer to avoid flicker const SPIRAL_BASE_R = 20; // spiral inner radius const SPIRAL_R_STEP = 5; // radius growth per room const SPIRAL_ANGLE_INC = 2.399; // golden angle (radians) const SPIRAL_Y_STEP = 1.5; // vertical rise per room const FLY_DURATION = 1.5; // seconds for fly-in tween const FLY_TARGET_DEPTH = ROOM_HALF - 1.5; // how deep inside to stop const ROOM_COLOR = 0x7b5cff; // violet — mnemosyne accent const POINT_COLOR = 0x9b7cff; const LABEL_COLOR = '#c8b4ff'; const STORAGE_KEY = 'mnemosyne_sessions_v1'; // ─── STATE ──────────────────────────────────────────── let _scene = null; let _camera = null; let _controls = null; let _rooms = []; // array of room objects let _sessionIndex = {}; // id → room object // Fly-in tween state let _flyActive = false; let _flyElapsed = 0; let _flyFrom = null; let _flyTo = null; let _flyLookFrom = null; let _flyLookTo = null; let _flyActiveRoom = null; // ─── SPIRAL POSITION ────────────────────────────────── function _spiralPos(index) { const angle = index * SPIRAL_ANGLE_INC; const r = SPIRAL_BASE_R + index * SPIRAL_R_STEP; const y = index * SPIRAL_Y_STEP; return new THREE.Vector3( Math.cos(angle) * r, y, Math.sin(angle) * r ); } // ─── CREATE ROOM ────────────────────────────────────── function _createRoom(session, index) { const pos = _spiralPos(index); const group = new THREE.Group(); group.position.copy(pos); // Wireframe cube const boxGeo = new THREE.BoxGeometry(ROOM_SIZE, ROOM_SIZE, ROOM_SIZE); const edgesGeo = new THREE.EdgesGeometry(boxGeo); const edgesMat = new THREE.LineBasicMaterial({ color: ROOM_COLOR, transparent: true, opacity: 0.55 }); const wireframe = new THREE.LineSegments(edgesGeo, edgesMat); wireframe.userData = { type: 'session_room_wireframe', sessionId: session.id }; group.add(wireframe); // Collision mesh (invisible, for raycasting) const hitGeo = new THREE.BoxGeometry(ROOM_SIZE, ROOM_SIZE, ROOM_SIZE); const hitMat = new THREE.MeshBasicMaterial({ visible: false, transparent: true, opacity: 0, side: THREE.FrontSide }); const hitMesh = new THREE.Mesh(hitGeo, hitMat); hitMesh.userData = { type: 'session_room', sessionId: session.id, roomIndex: index }; group.add(hitMesh); // LOD point (small sphere shown at distance) const pointGeo = new THREE.SphereGeometry(0.5, 6, 4); const pointMat = new THREE.MeshBasicMaterial({ color: POINT_COLOR, transparent: true, opacity: 0.7 }); const pointMesh = new THREE.Mesh(pointGeo, pointMat); pointMesh.userData = { type: 'session_room_point', sessionId: session.id }; pointMesh.visible = false; // starts hidden; shown only at LOD distance group.add(pointMesh); // Timestamp billboard sprite const sprite = _makeTimestampSprite(session.timestamp, session.facts.length); sprite.position.set(0, ROOM_HALF + 1.2, 0); group.add(sprite); // Inner ambient glow const glow = new THREE.PointLight(ROOM_COLOR, 0.4, ROOM_SIZE * 1.2); group.add(glow); _scene.add(group); const room = { session, group, wireframe, hitMesh, pointMesh, sprite, glow, pos: pos.clone(), index, lodActive: false, pulsePhase: Math.random() * Math.PI * 2 }; _rooms.push(room); _sessionIndex[session.id] = room; console.info('[SessionRooms] Created room for session', session.id, 'at index', index); return room; } // ─── TIMESTAMP SPRITE ──────────────────────────────── function _makeTimestampSprite(isoTimestamp, factCount) { const canvas = document.createElement('canvas'); canvas.width = 320; canvas.height = 72; const ctx = canvas.getContext('2d'); // Background pill ctx.clearRect(0, 0, 320, 72); ctx.fillStyle = 'rgba(20, 10, 40, 0.82)'; _roundRect(ctx, 4, 4, 312, 64, 14); ctx.fill(); // Border ctx.strokeStyle = 'rgba(123, 92, 255, 0.6)'; ctx.lineWidth = 1.5; _roundRect(ctx, 4, 4, 312, 64, 14); ctx.stroke(); // Timestamp text const dt = isoTimestamp ? new Date(isoTimestamp) : new Date(); const label = _formatDate(dt); ctx.fillStyle = LABEL_COLOR; ctx.font = 'bold 15px monospace'; ctx.textAlign = 'center'; ctx.fillText(label, 160, 30); // Fact count ctx.fillStyle = 'rgba(200, 180, 255, 0.65)'; ctx.font = '12px monospace'; ctx.fillText(factCount + (factCount === 1 ? ' fact' : ' facts'), 160, 52); const tex = new THREE.CanvasTexture(canvas); const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, opacity: 0.88 }); const sprite = new THREE.Sprite(mat); sprite.scale.set(5, 1.1, 1); sprite.userData = { type: 'session_room_label' }; return sprite; } // ─── HELPERS ────────────────────────────────────────── function _roundRect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r); ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r); ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y); ctx.closePath(); } function _formatDate(dt) { if (isNaN(dt.getTime())) return 'Unknown session'; const pad = n => String(n).padStart(2, '0'); return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; } // ─── DISPOSE ROOM ──────────────────────────────────── function _disposeRoom(room) { room.wireframe.geometry.dispose(); room.wireframe.material.dispose(); room.hitMesh.geometry.dispose(); room.hitMesh.material.dispose(); room.pointMesh.geometry.dispose(); room.pointMesh.material.dispose(); if (room.sprite.material.map) room.sprite.material.map.dispose(); room.sprite.material.dispose(); if (room.group.parent) room.group.parent.remove(room.group); delete _sessionIndex[room.session.id]; } // ─── PUBLIC: UPDATE SESSIONS ───────────────────────── // sessions: [{id, timestamp, facts:[{id,content,category,strength,...}]}] // Sorted chronologically oldest→newest; max MAX_ROOMS shown. function updateSessions(sessions) { if (!_scene) return; const sorted = [...sessions] .sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)) .slice(-MAX_ROOMS); // keep most recent MAX_ROOMS // Remove rooms no longer present const incoming = new Set(sorted.map(s => s.id)); for (let i = _rooms.length - 1; i >= 0; i--) { const room = _rooms[i]; if (!incoming.has(room.session.id)) { _disposeRoom(room); _rooms.splice(i, 1); } } // Add / update sorted.forEach((session, idx) => { if (_sessionIndex[session.id]) { // Update position if index changed const room = _sessionIndex[session.id]; if (room.index !== idx) { room.index = idx; const newPos = _spiralPos(idx); room.group.position.copy(newPos); room.pos.copy(newPos); } } else { _createRoom(session, idx); } }); saveToStorage(sorted); console.info('[SessionRooms] Updated:', _rooms.length, 'session rooms'); } // ─── PUBLIC: INIT ───────────────────────────────────── function init(scene, camera, controls) { _scene = scene; _camera = camera; _controls = controls; console.info('[SessionRooms] Initialized'); // Restore persisted sessions const saved = loadFromStorage(); if (saved && saved.length > 0) { updateSessions(saved); } } // ─── PUBLIC: UPDATE (per-frame) ─────────────────────── function update(delta) { if (!_scene || !_camera) return; const camPos = _camera.position; _rooms.forEach(room => { const dist = camPos.distanceTo(room.pos); // LOD toggle const threshold = room.lodActive ? LOD_THRESHOLD + LOD_HYSTERESIS // must come closer to exit LOD : LOD_THRESHOLD; if (dist > threshold && !room.lodActive) { room.lodActive = true; room.wireframe.visible = false; room.sprite.visible = false; room.pointMesh.visible = true; } else if (dist <= threshold && room.lodActive) { room.lodActive = false; room.wireframe.visible = true; room.sprite.visible = true; room.pointMesh.visible = false; } // Pulse wireframe opacity room.pulsePhase += delta * 0.6; if (!room.lodActive) { room.wireframe.material.opacity = 0.3 + Math.sin(room.pulsePhase) * 0.2; room.glow.intensity = 0.3 + Math.sin(room.pulsePhase * 1.4) * 0.15; } // Slowly rotate each room room.group.rotation.y += delta * 0.04; }); // Fly-in tween if (_flyActive) { _flyElapsed += delta; const t = Math.min(_flyElapsed / FLY_DURATION, 1); const ease = _easeInOut(t); _camera.position.lerpVectors(_flyFrom, _flyTo, ease); // Interpolate lookAt const lookNow = new THREE.Vector3().lerpVectors(_flyLookFrom, _flyLookTo, ease); _camera.lookAt(lookNow); if (_controls && _controls.target) _controls.target.copy(lookNow); if (t >= 1) { _flyActive = false; if (_controls && typeof _controls.update === 'function') _controls.update(); console.info('[SessionRooms] Fly-in complete for session', _flyActiveRoom && _flyActiveRoom.session.id); _flyActiveRoom = null; } } } // ─── EASING ─────────────────────────────────────────── function _easeInOut(t) { return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; } // ─── PUBLIC: GET CLICKABLE MESHES ───────────────────── function getClickableMeshes() { return _rooms.map(r => r.hitMesh); } // ─── PUBLIC: HANDLE ROOM CLICK ──────────────────────── function handleRoomClick(mesh) { const { sessionId } = mesh.userData; const room = _sessionIndex[sessionId]; if (!room || !_camera) return null; // Fly into the room from the front face _flyActive = true; _flyElapsed = 0; _flyActiveRoom = room; _flyFrom = _camera.position.clone(); // Target: step inside the room toward its center const dir = room.pos.clone().sub(_camera.position).normalize(); _flyTo = room.pos.clone().add(dir.multiplyScalar(FLY_TARGET_DEPTH)); _flyLookFrom = _controls && _controls.target ? _controls.target.clone() : _camera.position.clone().add(_camera.getWorldDirection(new THREE.Vector3())); _flyLookTo = room.pos.clone(); console.info('[SessionRooms] Flying into session room:', sessionId); return room.session; } // ─── PERSISTENCE ────────────────────────────────────── function saveToStorage(sessions) { if (typeof localStorage === 'undefined') return; try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ v: 1, sessions })); } catch (e) { console.warn('[SessionRooms] Failed to save to localStorage:', e); } } function loadFromStorage() { if (typeof localStorage === 'undefined') return null; try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return null; const parsed = JSON.parse(raw); if (!parsed || parsed.v !== 1 || !Array.isArray(parsed.sessions)) return null; console.info('[SessionRooms] Restored', parsed.sessions.length, 'sessions from localStorage'); return parsed.sessions; } catch (e) { console.warn('[SessionRooms] Failed to load from localStorage:', e); return null; } } function clearStorage() { if (typeof localStorage !== 'undefined') { localStorage.removeItem(STORAGE_KEY); console.info('[SessionRooms] Cleared localStorage'); } } // ─── PUBLIC API ─────────────────────────────────────── return { init, updateSessions, update, getClickableMeshes, handleRoomClick, clearStorage, // For external inspection getRooms: () => _rooms, getSession: (id) => _sessionIndex[id] || null, isFlyActive: () => _flyActive }; })(); export { SessionRooms };