Files
the-nexus/nexus/components/session-rooms.js
Claude (Opus 4.6) cc4af009c7
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 3s
CI / test (pull_request) Failing after 9s
CI / validate (pull_request) Failing after 13s
Review Approval Gate / verify-review (pull_request) Failing after 3s
[claude] Mnemosyne session rooms — holographic chambers per session (#1171) (#1178)
2026-04-10 22:45:10 +00:00

414 lines
15 KiB
JavaScript

// ═══════════════════════════════════════════════════════
// 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 };