Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions site/beacon/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,66 @@ export const CONTRACT_STATE_OPACITY = {
// ============================================================
export const REGION_RADIUS = 120;

/**
* Find agents by identity and visible Atlas metadata.
*
* Every whitespace-delimited query token must match somewhere in the agent's
* searchable fields. Exact identities and name-prefix matches rank ahead of
* broader role, capability, provider, source, status, or city matches.
*/
export function searchAgents(query, agents = AGENTS, limit = 8) {
const normalizedQuery = String(query ?? '').trim().toLowerCase();
if (!normalizedQuery) return [];

const tokens = normalizedQuery.split(/\s+/).filter(Boolean);
const safeLimit = Number.isFinite(Number(limit))
? Math.min(20, Math.max(1, Math.trunc(Number(limit))))
: 8;

return agents
.map((agent, index) => {
const city = CITIES.find(candidate => candidate.id === agent.city);
const name = String(agent.name ?? '').toLowerCase();
const id = String(agent.id ?? '').toLowerCase();
const beacon = String(agent.beacon ?? agent.relay_agent_id ?? '').toLowerCase();
const cityName = String(city?.name ?? '').toLowerCase();
const searchable = [
name,
id,
beacon,
agent.role,
agent.provider,
agent.model_id,
agent.status,
agent.grade,
agent.city,
cityName,
...(Array.isArray(agent.capabilities) ? agent.capabilities : []),
...(Array.isArray(agent.sources) ? agent.sources : []),
].map(value => String(value ?? '').toLowerCase()).join(' ');

if (!tokens.every(token => searchable.includes(token))) return null;

let rank = 6;
if (id === normalizedQuery || beacon === normalizedQuery || name === normalizedQuery) rank = 0;
else if (name.startsWith(normalizedQuery)) rank = 1;
else if (id.startsWith(normalizedQuery) || beacon.startsWith(normalizedQuery)) rank = 2;
else if (name.includes(normalizedQuery)) rank = 3;
else if (cityName.startsWith(normalizedQuery)) rank = 4;
else if (cityName.includes(normalizedQuery)) rank = 5;

return { agent, index, rank, name };
})
.filter(Boolean)
.sort((left, right) => (
left.rank - right.rank
|| left.name.localeCompare(right.name)
|| left.index - right.index
))
.slice(0, safeLimit)
.map(result => result.agent);
}

export function regionPosition(region) {
const rad = (region.angle * Math.PI) / 180;
return { x: Math.cos(rad) * REGION_RADIUS, z: Math.sin(rad) * REGION_RADIUS };
Expand Down
18 changes: 17 additions & 1 deletion site/beacon/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,28 @@
<div class="hud-action" id="hud-new-contract">[NEW CONTRACT]</div>
<div class="hud-action" id="hud-bounties" style="color:#ffd700">[BOUNTIES]</div>
</div>
<div class="agent-search" role="search">
<label class="sr-only" for="agent-search-input">Search Beacon Atlas agents</label>
<input
id="agent-search-input"
class="agent-search-input"
type="search"
placeholder="SEARCH AGENTS..."
autocomplete="off"
spellcheck="false"
role="combobox"
aria-autocomplete="list"
aria-controls="agent-search-results"
aria-expanded="false"
>
<div id="agent-search-results" class="agent-search-results" hidden></div>
</div>
</div>

<!-- Controls hint (bottom-left) -->
<div class="controls-hint">
DRAG rotate | SCROLL zoom | RIGHT-DRAG pan<br>
CLICK agent/city for details | ESC close
CLICK agent/city for details | / search | ESC close
</div>

<!-- Info Panel (right side) -->
Expand Down
113 changes: 113 additions & 0 deletions site/beacon/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,115 @@ html, body {
color: var(--green);
}

.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

.agent-search {
position: relative;
width: min(320px, calc(100vw - 32px));
margin-top: 10px;
pointer-events: auto;
font-family: var(--font-mono);
text-shadow: none;
}

.agent-search-input {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
border: 1px solid var(--green-dim);
border-radius: 3px;
outline: none;
background: rgba(0, 12, 0, 0.92);
color: var(--green);
caret-color: var(--amber);
font: 12px/1.4 var(--font-mono);
letter-spacing: 1px;
box-shadow: inset 0 0 12px rgba(0, 255, 0, 0.03);
}

.agent-search-input::placeholder {
color: var(--text-dim);
opacity: 0.8;
}

.agent-search-input:focus {
border-color: var(--green);
box-shadow: 0 0 10px rgba(0, 255, 0, 0.14), inset 0 0 12px rgba(0, 255, 0, 0.05);
}

.agent-search-results {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 280px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 3px;
background: rgba(0, 10, 0, 0.97);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.55), 0 0 12px rgba(0, 255, 0, 0.06);
}

.agent-search-results[hidden] {
display: none;
}

.agent-search-result {
display: flex;
width: 100%;
padding: 8px 10px;
border: 0;
border-bottom: 1px solid rgba(0, 255, 0, 0.08);
background: transparent;
color: var(--green);
cursor: pointer;
font-family: var(--font-mono);
text-align: left;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}

.agent-search-result:last-child {
border-bottom: 0;
}

.agent-search-result:hover,
.agent-search-result.active {
background: rgba(0, 255, 0, 0.09);
color: #8cff8c;
}

.agent-search-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}

.agent-search-meta,
.agent-search-empty {
color: var(--text-dim);
font-size: 10px;
white-space: nowrap;
}

.agent-search-empty {
padding: 9px 10px;
letter-spacing: 1px;
}

/* --- Controls hint (bottom-left) --- */
.controls-hint {
position: fixed;
Expand Down Expand Up @@ -753,6 +862,10 @@ html, body {
.hud-title { font-size: 22px; }
.hud-stats { font-size: 14px; }

.agent-search {
width: min(300px, calc(100vw - 16px));
}

.controls-hint { display: none; }
}

Expand Down
146 changes: 145 additions & 1 deletion site/beacon/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import {
AGENTS, CITIES, CONTRACTS, CALIBRATIONS,
GRADE_COLORS, cityRegion, addContract, getProviderColor, resolveAgentId,
GRADE_COLORS, cityRegion, addContract, getProviderColor, resolveAgentId, searchAgents,
} from './data.js';
import { lerpCameraTo, resetCamera, setClickHandler, setMissHandler, setHoverHandler } from './scene.js';
import { getAgentPosition, highlightAgent } from './agents.js';
Expand All @@ -20,6 +20,10 @@ let panel, panelContent, panelPath, tooltip;
let selectedAgent = null;
let selectedCity = null;
let hoveredId = null;
let searchInput = null;
let searchResults = null;
let searchMatches = [];
let activeSearchIndex = -1;

function escapeHtml(value) {
return String(value ?? '')
Expand Down Expand Up @@ -111,6 +115,7 @@ export function initUI() {

// HUD stats
updateHUD();
initAgentSearch();

// Click handlers
setClickHandler(onObjectClick);
Expand All @@ -137,6 +142,145 @@ export function initUI() {
window.addEventListener('hashchange', handleDeepLink);
}

function initAgentSearch() {
searchInput = document.getElementById('agent-search-input');
searchResults = document.getElementById('agent-search-results');
if (!searchInput || !searchResults) return;

const refreshResults = () => {
const query = searchInput.value.trim();
searchMatches = searchAgents(query);
activeSearchIndex = searchMatches.length > 0 ? 0 : -1;
renderAgentSearchResults(query);
};

searchInput.addEventListener('input', refreshResults);
searchInput.addEventListener('focus', refreshResults);
searchInput.addEventListener('keydown', event => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
if (searchMatches.length === 0) return;
event.preventDefault();
const direction = event.key === 'ArrowDown' ? 1 : -1;
const nextIndex = (activeSearchIndex + direction + searchMatches.length) % searchMatches.length;
setActiveSearchIndex(nextIndex);
return;
}

if (event.key === 'Enter') {
event.preventDefault();
chooseSearchResult(activeSearchIndex);
return;
}

if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
searchInput.value = '';
searchMatches = [];
hideAgentSearchResults();
searchInput.blur();
}
});

document.addEventListener('keydown', event => {
const target = event.target;
const isTyping = target instanceof HTMLElement
&& (target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName));
if (event.key === '/' && !isTyping && !event.ctrlKey && !event.metaKey && !event.altKey) {
event.preventDefault();
searchInput.focus();
}
});

document.addEventListener('pointerdown', event => {
const target = event.target;
if (!(target instanceof Element) || !target.closest('.agent-search')) {
hideAgentSearchResults();
}
});
}

function renderAgentSearchResults(query) {
searchResults.replaceChildren();
searchInput.setAttribute('aria-expanded', query ? 'true' : 'false');

if (!query) {
hideAgentSearchResults();
return;
}

searchResults.hidden = false;

if (searchMatches.length === 0) {
const empty = document.createElement('div');
empty.className = 'agent-search-empty';
empty.textContent = 'NO MATCHING AGENTS';
searchResults.append(empty);
return;
}

searchMatches.forEach((agent, index) => {
const city = CITIES.find(candidate => candidate.id === agent.city);
const option = document.createElement('button');
option.type = 'button';
option.className = 'agent-search-result';
option.id = `agent-search-result-${index}`;
option.dataset.index = String(index);
option.setAttribute('aria-current', index === activeSearchIndex ? 'true' : 'false');

const name = document.createElement('span');
name.className = 'agent-search-name';
name.textContent = agent.name || agent.id;

const meta = document.createElement('span');
meta.className = 'agent-search-meta';
const status = agent.status ? ` · ${String(agent.status).toUpperCase()}` : '';
meta.textContent = `${city?.name || agent.city || 'Unknown city'}${status}`;

option.append(name, meta);
option.addEventListener('pointerenter', () => setActiveSearchIndex(index));
option.addEventListener('click', () => chooseSearchResult(index));
searchResults.append(option);
});

setActiveSearchIndex(activeSearchIndex, false);
}

function setActiveSearchIndex(index, scroll = true) {
activeSearchIndex = index;
const options = [...searchResults.querySelectorAll('.agent-search-result')];
options.forEach((option, optionIndex) => {
const active = optionIndex === activeSearchIndex;
option.classList.toggle('active', active);
option.setAttribute('aria-current', active ? 'true' : 'false');
});

const activeOption = options[activeSearchIndex];
if (activeOption) {
searchInput.setAttribute('aria-activedescendant', activeOption.id);
if (scroll) activeOption.scrollIntoView({ block: 'nearest' });
} else {
searchInput.removeAttribute('aria-activedescendant');
}
}

function chooseSearchResult(index) {
const agent = searchMatches[index];
if (!agent) return;
searchInput.value = agent.name || agent.id;
hideAgentSearchResults();
searchInput.blur();
selectAgent(agent.id);
}

function hideAgentSearchResults() {
if (!searchResults || !searchInput) return;
searchResults.hidden = true;
searchInput.setAttribute('aria-expanded', 'false');
searchInput.removeAttribute('aria-activedescendant');
activeSearchIndex = -1;
}

function updateHUD() {
const el = document.querySelector('.hud-stats');
if (el) {
Expand Down
Loading