Compare commits
4 Commits
main
...
feat/spati
| Author | SHA1 | Date | |
|---|---|---|---|
| 72eecf6ee4 | |||
| 0bd3e1f470 | |||
| b73d846334 | |||
| 4c267de5bc |
116
app.js
116
app.js
@@ -3444,6 +3444,122 @@ init().then(() => {
|
||||
// Gravity well clustering — attract related crystals, bake positions (issue #1175)
|
||||
SpatialMemory.runGravityLayout();
|
||||
|
||||
|
||||
// ═══ SPATIAL SEARCH (Mnemosyne #1170) ═══
|
||||
(() => {
|
||||
const input = document.getElementById('spatial-search-input');
|
||||
const resultsDiv = document.getElementById('spatial-search-results');
|
||||
if (!input || !resultsDiv) return;
|
||||
|
||||
let searchTimeout = null;
|
||||
let currentMatches = [];
|
||||
|
||||
function runSearch(query) {
|
||||
if (!query.trim()) {
|
||||
SpatialMemory.clearSearch();
|
||||
resultsDiv.classList.remove('visible');
|
||||
resultsDiv.innerHTML = '';
|
||||
currentMatches = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = SpatialMemory.searchContent(query);
|
||||
currentMatches = matches;
|
||||
|
||||
if (matches.length === 0) {
|
||||
SpatialMemory.clearSearch();
|
||||
resultsDiv.innerHTML = '<div class="spatial-search-count">No matches</div>';
|
||||
resultsDiv.classList.add('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
SpatialMemory.highlightSearchResults(matches);
|
||||
|
||||
// Build results list
|
||||
const allMems = SpatialMemory.getAllMemories();
|
||||
let html = `<div class="spatial-search-count">${matches.length} match${matches.length > 1 ? 'es' : ''}</div>`;
|
||||
matches.forEach(id => {
|
||||
const mem = allMems.find(m => m.id === id);
|
||||
if (mem) {
|
||||
const label = (mem.content || id).slice(0, 60);
|
||||
const region = mem.category || '?';
|
||||
html += `<div class="spatial-search-result-item" data-mem-id="${id}">
|
||||
<span class="result-region">[${region}]</span>${label}
|
||||
</div>`;
|
||||
}
|
||||
});
|
||||
resultsDiv.innerHTML = html;
|
||||
resultsDiv.classList.add('visible');
|
||||
|
||||
// Click handler for result items
|
||||
resultsDiv.querySelectorAll('.spatial-search-result-item').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const memId = el.getAttribute('data-mem-id');
|
||||
flyToMemory(memId);
|
||||
});
|
||||
});
|
||||
|
||||
// Fly camera to first match
|
||||
if (matches.length > 0) {
|
||||
flyToMemory(matches[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function flyToMemory(memId) {
|
||||
const pos = SpatialMemory.getSearchMatchPosition(memId);
|
||||
if (!pos) return;
|
||||
|
||||
// Smooth camera fly-to: place camera above and in front of crystal
|
||||
const targetPos = new THREE.Vector3(pos.x, pos.y + 4, pos.z + 6);
|
||||
|
||||
// Use simple lerp animation over ~800ms
|
||||
const startPos = playerPos.clone();
|
||||
const startTime = performance.now();
|
||||
const duration = 800;
|
||||
|
||||
function animateCamera(now) {
|
||||
const elapsed = now - startTime;
|
||||
const t = Math.min(1, elapsed / duration);
|
||||
// Ease out cubic
|
||||
const ease = 1 - Math.pow(1 - t, 3);
|
||||
|
||||
playerPos.lerpVectors(startPos, targetPos, ease);
|
||||
camera.position.copy(playerPos);
|
||||
|
||||
// Look at crystal
|
||||
const lookTarget = pos.clone();
|
||||
lookTarget.y += 1.5;
|
||||
camera.lookAt(lookTarget);
|
||||
|
||||
if (t < 1) {
|
||||
requestAnimationFrame(animateCamera);
|
||||
} else {
|
||||
SpatialMemory.highlightMemory(memId);
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(animateCamera);
|
||||
}
|
||||
|
||||
// Debounced input handler
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => runSearch(input.value), 200);
|
||||
});
|
||||
|
||||
// Escape clears search
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
input.value = '';
|
||||
SpatialMemory.clearSearch();
|
||||
resultsDiv.classList.remove('visible');
|
||||
resultsDiv.innerHTML = '';
|
||||
currentMatches = [];
|
||||
input.blur();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
// Project Mnemosyne — seed demo session rooms (#1171)
|
||||
// Sessions group facts by conversation/work session with a timestamp.
|
||||
const demoSessions = [
|
||||
|
||||
@@ -66,6 +66,14 @@ chdir: error retrieving current directory: getcwd: cannot access parent director
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Spatial Search Overlay (Mnemosyne #1170) -->
|
||||
<div id="spatial-search" class="spatial-search-overlay">
|
||||
<input type="text" id="spatial-search-input" class="spatial-search-input"
|
||||
placeholder="🔍 Search memories..." autocomplete="off" spellcheck="false">
|
||||
<div id="spatial-search-results" class="spatial-search-results"></div>
|
||||
</div>
|
||||
|
||||
<!-- HUD Overlay -->
|
||||
<div id="hud" class="game-ui" style="display:none;">
|
||||
<!-- GOFAI HUD Panels -->
|
||||
|
||||
@@ -734,13 +734,86 @@ const SpatialMemory = (() => {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ─── SPATIAL SEARCH (issue #1170) ────────────────────
|
||||
let _searchOriginalState = {}; // memId -> { emissiveIntensity, opacity } for restore
|
||||
|
||||
function searchContent(query) {
|
||||
if (!query || !query.trim()) return [];
|
||||
const q = query.toLowerCase().trim();
|
||||
const matches = [];
|
||||
|
||||
Object.values(_memoryObjects).forEach(obj => {
|
||||
const d = obj.data;
|
||||
const searchable = [
|
||||
d.content || '',
|
||||
d.id || '',
|
||||
d.category || '',
|
||||
d.source || '',
|
||||
...(d.connections || [])
|
||||
].join(' ').toLowerCase();
|
||||
|
||||
if (searchable.includes(q)) {
|
||||
matches.push(d.id);
|
||||
}
|
||||
});
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function highlightSearchResults(matchIds) {
|
||||
// Save original state and apply search highlighting
|
||||
_searchOriginalState = {};
|
||||
const matchSet = new Set(matchIds);
|
||||
|
||||
Object.entries(_memoryObjects).forEach(([id, obj]) => {
|
||||
const mat = obj.mesh.material;
|
||||
_searchOriginalState[id] = {
|
||||
emissiveIntensity: mat.emissiveIntensity,
|
||||
opacity: mat.opacity
|
||||
};
|
||||
|
||||
if (matchSet.has(id)) {
|
||||
// Match: bright white glow
|
||||
mat.emissive.setHex(0xffffff);
|
||||
mat.emissiveIntensity = 5.0;
|
||||
mat.opacity = 1.0;
|
||||
} else {
|
||||
// Non-match: dim to 10% opacity
|
||||
mat.opacity = 0.1;
|
||||
mat.emissiveIntensity = 0.2;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
Object.entries(_memoryObjects).forEach(([id, obj]) => {
|
||||
const mat = obj.mesh.material;
|
||||
const saved = _searchOriginalState[id];
|
||||
if (saved) {
|
||||
// Restore original emissive color from region
|
||||
const region = REGIONS[obj.region] || REGIONS.working;
|
||||
mat.emissive.copy(region.color);
|
||||
mat.emissiveIntensity = saved.emissiveIntensity;
|
||||
mat.opacity = saved.opacity;
|
||||
}
|
||||
});
|
||||
_searchOriginalState = {};
|
||||
}
|
||||
|
||||
function getSearchMatchPosition(matchId) {
|
||||
const obj = _memoryObjects[matchId];
|
||||
return obj ? obj.mesh.position.clone() : null;
|
||||
}
|
||||
|
||||
return {
|
||||
init, placeMemory, removeMemory, update,
|
||||
getMemoryAtPosition, getRegionAtPosition, getMemoriesInRegion, getAllMemories,
|
||||
getCrystalMeshes, getMemoryFromMesh, highlightMemory, clearHighlight, getSelectedId,
|
||||
exportIndex, importIndex, exportToFile, importFromFile, searchNearby, REGIONS,
|
||||
saveToStorage, loadFromStorage, clearStorage,
|
||||
runGravityLayout
|
||||
runGravityLayout,
|
||||
searchContent, highlightSearchResults, clearSearch, getSearchMatchPosition
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
81
style.css
81
style.css
@@ -1744,3 +1744,84 @@ canvas#nexus-canvas {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
|
||||
/* ═══ SPATIAL SEARCH OVERLAY (Mnemosyne #1170) ═══ */
|
||||
.spatial-search-overlay {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.spatial-search-input {
|
||||
width: 260px;
|
||||
padding: 8px 14px;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
border: 1px solid rgba(74, 240, 192, 0.3);
|
||||
border-radius: 6px;
|
||||
color: #e0f0ff;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
backdrop-filter: blur(8px);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.spatial-search-input:focus {
|
||||
border-color: rgba(74, 240, 192, 0.7);
|
||||
box-shadow: 0 0 12px rgba(74, 240, 192, 0.15);
|
||||
}
|
||||
|
||||
.spatial-search-input::placeholder {
|
||||
color: rgba(224, 240, 255, 0.35);
|
||||
}
|
||||
|
||||
.spatial-search-results {
|
||||
margin-top: 4px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border: 1px solid rgba(74, 240, 192, 0.15);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: #a0c0d0;
|
||||
width: 260px;
|
||||
backdrop-filter: blur(8px);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.spatial-search-results.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.spatial-search-result-item {
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid rgba(74, 240, 192, 0.08);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.spatial-search-result-item:hover {
|
||||
background: rgba(74, 240, 192, 0.1);
|
||||
color: #e0f0ff;
|
||||
}
|
||||
|
||||
.spatial-search-result-item .result-region {
|
||||
color: #4af0c0;
|
||||
font-size: 9px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.spatial-search-count {
|
||||
padding: 4px 10px;
|
||||
color: rgba(74, 240, 192, 0.6);
|
||||
font-size: 10px;
|
||||
border-bottom: 1px solid rgba(74, 240, 192, 0.1);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user