Compare commits

...

4 Commits

Author SHA1 Message Date
72eecf6ee4 feat: wire spatial search — keyword search, highlight, camera fly-to
Some checks failed
CI / test (pull_request) Failing after 9s
CI / validate (pull_request) Failing after 11s
Review Approval Gate / verify-review (pull_request) Failing after 3s
Issue #1170
Search bar overlays top-right. Typing searches memory content/IDs.
Matches glow white, non-matches dim. Click or auto-fly to first match.
Escape clears search.
2026-04-11 00:20:33 +00:00
0bd3e1f470 feat: add spatial search overlay styles
Issue #1170
2026-04-11 00:20:08 +00:00
b73d846334 feat: add spatial search overlay UI — top-right input bar
Issue #1170
2026-04-11 00:20:03 +00:00
4c267de5bc feat: add spatial search — keyword search, highlight matches, dim non-matches
Issue #1170
Searches memory content, IDs, categories. Matches glow white,
non-matches dim to 10% opacity. clearSearch() restores original state.
2026-04-11 00:19:25 +00:00
4 changed files with 279 additions and 1 deletions

116
app.js
View File

@@ -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 = [

View File

@@ -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 -->

View File

@@ -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
};
})();

View File

@@ -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);
}