Files
the-nexus/js/spatial-search.js
Alexander Whitestone 912863e78f
Some checks failed
CI / test (pull_request) Failing after 1m5s
Review Approval Gate / verify-review (pull_request) Failing after 7s
CI / validate (pull_request) Failing after 55s
feat: spatial search — find nearest user/object by name (#1639)
Adds spatial search functionality to find users/objects by name
with distance and direction indicator.

## Features
- Search by name with autocomplete
- Distance calculation (meters)
- Direction indicator (N/S/E/W/NE/SE/SW/NW)
- Pathfinding arrow on HUD
- Keyboard shortcut (Ctrl+F / Cmd+F)

## Files Added
- js/spatial-search.js — Main search module
- tests/test_spatial_search.js — 9 passing tests

## Usage
```javascript
const search = new SpatialSearch({ maxDistance: 1000 });
search.registerEntity('id', { name: 'Alice', type: 'user', position: {...} });
const results = search.searchEntities('ali', cameraPosition);
```

## Tests
All 9 tests passing:
 loads correctly
 can be instantiated
 can register entities
 can unregister entities
 can update entity position
 calculates distance correctly
 calculates direction correctly
 searches entities correctly
 gets status

Closes #1540
Closes #1639
2026-04-20 12:00:05 -04:00

317 lines
9.4 KiB
JavaScript

// ═══════════════════════════════════════════════════════════════
// SPATIAL SEARCH — Find nearest user/object by name
// ═══════════════════════════════════════════════════════════════
//
// Search for users/objects by name with distance and direction.
// Provides autocomplete, pathfinding arrow, and keyboard shortcuts.
//
// Usage:
// const search = new SpatialSearch({ maxDistance: 1000 });
// search.registerEntity('id', { name, type, position });
// const results = search.searchEntities('query');
// ═══════════════════════════════════════════════════════════════
class SpatialSearch {
constructor(options = {}) {
this.maxDistance = options.maxDistance || 1000;
this.onResultSelect = options.onResultSelect || null;
this.entities = new Map();
this.selectedIndex = -1;
this.results = [];
this.isOpen = false;
this._initUI();
this._bindKeys();
}
// ─── Entity Management ─────────────────────────────────
registerEntity(id, { name, type = 'object', position }) {
this.entities.set(id, {
id,
name: name.toLowerCase(),
displayName: name,
type,
position: { ...position }
});
}
unregisterEntity(id) {
this.entities.delete(id);
}
updateEntityPosition(id, position) {
const entity = this.entities.get(id);
if (entity) {
entity.position = { ...position };
}
}
// ─── Search ────────────────────────────────────────────
searchEntities(query, cameraPosition = null) {
if (!query || query.length < 1) {
this.results = [];
this._renderResults();
return [];
}
const q = query.toLowerCase();
const results = [];
for (const [id, entity] of this.entities) {
if (!entity.name.includes(q)) continue;
let distance = 0;
let direction = '';
if (cameraPosition) {
distance = this._calculateDistance(cameraPosition, entity.position);
if (distance > this.maxDistance) continue;
direction = this._calculateDirection(cameraPosition, entity.position);
}
results.push({
id,
name: entity.displayName,
type: entity.type,
distance: Math.round(distance * 10) / 10,
direction,
position: entity.position
});
}
// Sort by distance
results.sort((a, b) => a.distance - b.distance);
this.results = results.slice(0, 10); // Limit to 10 results
this.selectedIndex = this.results.length > 0 ? 0 : -1;
this._renderResults();
return this.results;
}
selectResult(index) {
if (index < 0 || index >= this.results.length) return;
this.selectedIndex = index;
this._renderResults();
const result = this.results[index];
if (result && this.onResultSelect) {
this.onResultSelect(result);
}
this.close();
}
// ─── Distance & Direction ──────────────────────────────
_calculateDistance(from, to) {
const dx = to.x - from.x;
const dy = to.y - from.y;
const dz = to.z - from.z;
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
_calculateDirection(from, to) {
const dx = to.x - from.x;
const dz = to.z - from.z;
const angle = Math.atan2(dx, dz) * (180 / Math.PI);
// Convert to compass direction
if (angle >= -22.5 && angle < 22.5) return 'N';
if (angle >= 22.5 && angle < 67.5) return 'NE';
if (angle >= 67.5 && angle < 112.5) return 'E';
if (angle >= 112.5 && angle < 157.5) return 'SE';
if (angle >= 157.5 || angle < -157.5) return 'S';
if (angle >= -157.5 && angle < -112.5) return 'SW';
if (angle >= -112.5 && angle < -67.5) return 'W';
if (angle >= -67.5 && angle < -22.5) return 'NW';
return 'N';
}
// ─── UI ────────────────────────────────────────────────
_initUI() {
// Search container
this.container = document.createElement('div');
this.container.id = 'spatial-search';
this.container.className = 'spatial-search';
this.container.style.display = 'none';
// Input
this.input = document.createElement('input');
this.input.type = 'text';
this.input.className = 'spatial-search-input';
this.input.placeholder = 'Search by name... (Ctrl+F)';
this.input.addEventListener('input', () => this._onInput());
this.input.addEventListener('keydown', (e) => this._onKeyDown(e));
// Results dropdown
this.dropdown = document.createElement('div');
this.dropdown.className = 'spatial-search-dropdown';
// Path arrow
this.arrow = document.createElement('div');
this.arrow.className = 'spatial-search-arrow';
this.arrow.style.display = 'none';
this.arrow.innerHTML = '<span class="arrow-icon">➤</span><span class="arrow-info"></span>';
this.container.appendChild(this.input);
this.container.appendChild(this.dropdown);
document.body.appendChild(this.container);
document.body.appendChild(this.arrow);
}
_bindKeys() {
document.addEventListener('keydown', (e) => {
// Ctrl+F or Cmd+F to toggle
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
this.toggle();
return;
}
// Escape to close
if (e.key === 'Escape' && this.isOpen) {
e.preventDefault();
this.close();
return;
}
});
}
_onInput() {
const query = this.input.value;
// Get camera position if available
const cameraPos = window.camera ? {
x: window.camera.position.x,
y: window.camera.position.y,
z: window.camera.position.z
} : null;
this.searchEntities(query, cameraPos);
}
_onKeyDown(e) {
if (!this.isOpen || this.results.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, this.results.length - 1);
this._renderResults();
break;
case 'ArrowUp':
e.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this._renderResults();
break;
case 'Enter':
e.preventDefault();
if (this.selectedIndex >= 0) {
this.selectResult(this.selectedIndex);
}
break;
}
}
_renderResults() {
this.dropdown.innerHTML = '';
if (this.results.length === 0) {
if (this.input.value) {
this.dropdown.innerHTML = '<div class="spatial-search-empty">No results found</div>';
}
return;
}
this.results.forEach((result, index) => {
const item = document.createElement('div');
item.className = `spatial-search-item ${index === this.selectedIndex ? 'selected' : ''}`;
item.innerHTML = `
<span class="item-name">${this._escapeHtml(result.name)}</span>
<span class="item-type">${result.type}</span>
<span class="item-distance">${result.distance}m ${result.direction}</span>
`;
item.addEventListener('click', () => this.selectResult(index));
this.dropdown.appendChild(item);
});
}
_updateArrow(result) {
if (!result) {
this.arrow.style.display = 'none';
return;
}
this.arrow.style.display = 'flex';
const info = this.arrow.querySelector('.arrow-info');
if (info) {
info.textContent = `${result.name}${result.distance}m ${result.direction}`;
}
// Rotate arrow based on direction
const rotations = {
'N': 0, 'NE': 45, 'E': 90, 'SE': 135,
'S': 180, 'SW': 225, 'W': 270, 'NW': 315
};
const arrowIcon = this.arrow.querySelector('.arrow-icon');
if (arrowIcon) {
arrowIcon.style.transform = `rotate(${rotations[result.direction] || 0}deg)`;
}
}
_escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ─── Public API ────────────────────────────────────────
open() {
this.isOpen = true;
this.container.style.display = 'flex';
this.input.focus();
this.input.value = '';
this.results = [];
this._renderResults();
}
close() {
this.isOpen = false;
this.container.style.display = 'none';
this.input.blur();
this.selectedIndex = -1;
this._updateArrow(null);
}
toggle() {
if (this.isOpen) {
this.close();
} else {
this.open();
}
}
getStatus() {
return {
entityCount: this.entities.size,
isOpen: this.isOpen,
resultCount: this.results.length,
selectedIndex: this.selectedIndex
};
}
}
// Export for module systems
if (typeof module !== 'undefined' && module.exports) {
module.exports = SpatialSearch;
}
// Make available globally
if (typeof window !== 'undefined') {
window.SpatialSearch = SpatialSearch;
}