Add real-time radar feature for nearby objects
Browser can open a radar window per character that streams nearby monsters, players, NPCs, portals, and other objects in real-time. On-demand activation via start_radar/stop_radar commands through the existing WebSocket command channel. - Backend: nearby_objects event handler with in-memory cache and broadcast - Frontend: canvas mini-map + entity list table in draggable window - Radar button added to player list alongside Chat/Stats/Inventory/Char Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c0da36c280
commit
3852cf205e
3 changed files with 439 additions and 1 deletions
9
main.py
9
main.py
|
|
@ -981,6 +981,7 @@ live_snapshots: Dict[str, dict] = {}
|
||||||
live_vitals: Dict[str, dict] = {}
|
live_vitals: Dict[str, dict] = {}
|
||||||
live_character_stats: Dict[str, dict] = {}
|
live_character_stats: Dict[str, dict] = {}
|
||||||
live_equipment_cantrip_states: Dict[str, dict] = {}
|
live_equipment_cantrip_states: Dict[str, dict] = {}
|
||||||
|
live_nearby_objects: Dict[str, dict] = {}
|
||||||
|
|
||||||
# Shared secret used to authenticate plugin WebSocket connections (override for production)
|
# Shared secret used to authenticate plugin WebSocket connections (override for production)
|
||||||
SHARED_SECRET = "your_shared_secret"
|
SHARED_SECRET = "your_shared_secret"
|
||||||
|
|
@ -2677,6 +2678,13 @@ async def ws_receive_snapshots(
|
||||||
f"Invalid portal message format from {websocket.client}: missing required fields"
|
f"Invalid portal message format from {websocket.client}: missing required fields"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if msg_type == "nearby_objects":
|
||||||
|
character_name = data.get("character_name")
|
||||||
|
if character_name:
|
||||||
|
live_nearby_objects[character_name] = data
|
||||||
|
await _broadcast_to_browser_clients(data)
|
||||||
|
continue
|
||||||
# Unknown message types are ignored
|
# Unknown message types are ignored
|
||||||
if msg_type:
|
if msg_type:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -2691,6 +2699,7 @@ async def ws_receive_snapshots(
|
||||||
for name in disconnected_names:
|
for name in disconnected_names:
|
||||||
plugin_conns.pop(name, None)
|
plugin_conns.pop(name, None)
|
||||||
live_equipment_cantrip_states.pop(name, None)
|
live_equipment_cantrip_states.pop(name, None)
|
||||||
|
live_nearby_objects.pop(name, None)
|
||||||
|
|
||||||
# Clean up any plugin registrations for this socket
|
# Clean up any plugin registrations for this socket
|
||||||
to_remove = [n for n, ws in plugin_conns.items() if ws is websocket]
|
to_remove = [n for n, ws in plugin_conns.items() if ws is websocket]
|
||||||
|
|
|
||||||
301
static/script.js
301
static/script.js
|
|
@ -170,10 +170,22 @@ function createNewListItem() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const radarBtn = document.createElement('button');
|
||||||
|
radarBtn.className = 'radar-btn';
|
||||||
|
radarBtn.textContent = 'Radar';
|
||||||
|
radarBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const playerData = e.currentTarget.playerData || e.target.closest('li.player-item')?.playerData;
|
||||||
|
if (playerData) {
|
||||||
|
showRadarWindow(playerData.character_name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
buttonsContainer.appendChild(chatBtn);
|
buttonsContainer.appendChild(chatBtn);
|
||||||
buttonsContainer.appendChild(statsBtn);
|
buttonsContainer.appendChild(statsBtn);
|
||||||
buttonsContainer.appendChild(inventoryBtn);
|
buttonsContainer.appendChild(inventoryBtn);
|
||||||
buttonsContainer.appendChild(charBtn);
|
buttonsContainer.appendChild(charBtn);
|
||||||
|
buttonsContainer.appendChild(radarBtn);
|
||||||
li.appendChild(buttonsContainer);
|
li.appendChild(buttonsContainer);
|
||||||
|
|
||||||
// Store references for easy access
|
// Store references for easy access
|
||||||
|
|
@ -182,6 +194,7 @@ function createNewListItem() {
|
||||||
li.statsBtn = statsBtn;
|
li.statsBtn = statsBtn;
|
||||||
li.inventoryBtn = inventoryBtn;
|
li.inventoryBtn = inventoryBtn;
|
||||||
li.charBtn = charBtn;
|
li.charBtn = charBtn;
|
||||||
|
li.radarBtn = radarBtn;
|
||||||
|
|
||||||
return li;
|
return li;
|
||||||
}
|
}
|
||||||
|
|
@ -2875,6 +2888,7 @@ function render(players) {
|
||||||
if (li.chatBtn) li.chatBtn.playerData = p;
|
if (li.chatBtn) li.chatBtn.playerData = p;
|
||||||
if (li.statsBtn) li.statsBtn.playerData = p;
|
if (li.statsBtn) li.statsBtn.playerData = p;
|
||||||
if (li.inventoryBtn) li.inventoryBtn.playerData = p;
|
if (li.inventoryBtn) li.inventoryBtn.playerData = p;
|
||||||
|
if (li.radarBtn) li.radarBtn.playerData = p;
|
||||||
|
|
||||||
// Only reorder element if it's actually out of place for current sort order
|
// Only reorder element if it's actually out of place for current sort order
|
||||||
// Check if this element needs to be moved to maintain sort order
|
// Check if this element needs to be moved to maintain sort order
|
||||||
|
|
@ -3051,6 +3065,8 @@ function initWebSocket() {
|
||||||
updateInventoryLive(msg);
|
updateInventoryLive(msg);
|
||||||
} else if (msg.type === 'server_status') {
|
} else if (msg.type === 'server_status') {
|
||||||
handleServerStatusUpdate(msg);
|
handleServerStatusUpdate(msg);
|
||||||
|
} else if (msg.type === 'nearby_objects') {
|
||||||
|
updateRadarWindow(msg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
socket.addEventListener('close', () => setTimeout(initWebSocket, 2000));
|
socket.addEventListener('close', () => setTimeout(initWebSocket, 2000));
|
||||||
|
|
@ -3559,4 +3575,289 @@ function openPlayerDashboard() {
|
||||||
window.open('/player-dashboard.html', '_blank');
|
window.open('/player-dashboard.html', '_blank');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Radar Window ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const radarWindows = {}; // character_name -> window element
|
||||||
|
|
||||||
|
const RADAR_COLORS = {
|
||||||
|
Monster: '#ff4444',
|
||||||
|
Player: '#4488ff',
|
||||||
|
NPC: '#44cc44',
|
||||||
|
Vendor: '#44cc44',
|
||||||
|
Portal: '#aa44ff',
|
||||||
|
Corpse: '#ff8800',
|
||||||
|
Container: '#cccc44',
|
||||||
|
Door: '#888888',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RADAR_CANVAS_SIZE = 300;
|
||||||
|
const RADAR_DEFAULT_RANGE = 0.5; // AC coordinate units (~120m)
|
||||||
|
|
||||||
|
function showRadarWindow(name) {
|
||||||
|
const windowId = `radarWindow-${name}`;
|
||||||
|
|
||||||
|
const { win, content, isNew } = createWindow(
|
||||||
|
windowId, `Radar: ${name}`, 'radar-window', {
|
||||||
|
onClose: () => {
|
||||||
|
// Send stop_radar command
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(JSON.stringify({ player_name: name, command: 'stop_radar' }));
|
||||||
|
}
|
||||||
|
delete radarWindows[name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isNew) return;
|
||||||
|
|
||||||
|
radarWindows[name] = win;
|
||||||
|
win.dataset.character = name;
|
||||||
|
|
||||||
|
// Radar controls
|
||||||
|
const controls = document.createElement('div');
|
||||||
|
controls.className = 'radar-controls';
|
||||||
|
|
||||||
|
const rangeLabel = document.createElement('label');
|
||||||
|
rangeLabel.textContent = 'Range: ';
|
||||||
|
const rangeSelect = document.createElement('select');
|
||||||
|
rangeSelect.className = 'radar-range-select';
|
||||||
|
[
|
||||||
|
{ value: '0.2', label: 'Close (~50m)' },
|
||||||
|
{ value: '0.5', label: 'Medium (~120m)' },
|
||||||
|
{ value: '1.0', label: 'Far (~240m)' },
|
||||||
|
{ value: '2.0', label: 'Very Far (~480m)' },
|
||||||
|
].forEach(opt => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = opt.value;
|
||||||
|
o.textContent = opt.label;
|
||||||
|
if (opt.value === '0.5') o.selected = true;
|
||||||
|
rangeSelect.appendChild(o);
|
||||||
|
});
|
||||||
|
rangeLabel.appendChild(rangeSelect);
|
||||||
|
controls.appendChild(rangeLabel);
|
||||||
|
|
||||||
|
const headingToggle = document.createElement('label');
|
||||||
|
headingToggle.className = 'radar-heading-toggle';
|
||||||
|
const headingCheck = document.createElement('input');
|
||||||
|
headingCheck.type = 'checkbox';
|
||||||
|
headingCheck.checked = false;
|
||||||
|
headingToggle.appendChild(headingCheck);
|
||||||
|
headingToggle.appendChild(document.createTextNode(' Heading-up'));
|
||||||
|
controls.appendChild(headingToggle);
|
||||||
|
|
||||||
|
content.appendChild(controls);
|
||||||
|
|
||||||
|
// Canvas
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.className = 'radar-canvas';
|
||||||
|
canvas.width = RADAR_CANVAS_SIZE;
|
||||||
|
canvas.height = RADAR_CANVAS_SIZE;
|
||||||
|
content.appendChild(canvas);
|
||||||
|
|
||||||
|
// Entity list
|
||||||
|
const listContainer = document.createElement('div');
|
||||||
|
listContainer.className = 'radar-entity-list';
|
||||||
|
|
||||||
|
const listHeader = document.createElement('div');
|
||||||
|
listHeader.className = 'radar-entity-header';
|
||||||
|
listHeader.innerHTML = '<span class="re-color"></span><span class="re-name">Name</span><span class="re-type">Type</span><span class="re-dist">Dist</span><span class="re-dir">Dir</span>';
|
||||||
|
listContainer.appendChild(listHeader);
|
||||||
|
|
||||||
|
const listBody = document.createElement('div');
|
||||||
|
listBody.className = 'radar-entity-body';
|
||||||
|
listContainer.appendChild(listBody);
|
||||||
|
content.appendChild(listContainer);
|
||||||
|
|
||||||
|
// Store refs on the window
|
||||||
|
win._radarCanvas = canvas;
|
||||||
|
win._radarListBody = listBody;
|
||||||
|
win._radarRangeSelect = rangeSelect;
|
||||||
|
win._radarHeadingCheck = headingCheck;
|
||||||
|
|
||||||
|
// Send start_radar command
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(JSON.stringify({ player_name: name, command: 'start_radar' }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRadarWindow(msg) {
|
||||||
|
const name = msg.character_name;
|
||||||
|
const win = radarWindows[name];
|
||||||
|
if (!win || win.style.display === 'none') return;
|
||||||
|
|
||||||
|
const canvas = win._radarCanvas;
|
||||||
|
const listBody = win._radarListBody;
|
||||||
|
const range = parseFloat(win._radarRangeSelect.value) || RADAR_DEFAULT_RANGE;
|
||||||
|
const headingUp = win._radarHeadingCheck.checked;
|
||||||
|
const objects = msg.objects || [];
|
||||||
|
|
||||||
|
const playerEW = msg.player_ew;
|
||||||
|
const playerNS = msg.player_ns;
|
||||||
|
const playerHeading = msg.player_heading || 0;
|
||||||
|
|
||||||
|
// ─── Draw radar canvas ───
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const size = RADAR_CANVAS_SIZE;
|
||||||
|
const cx = size / 2;
|
||||||
|
const cy = size / 2;
|
||||||
|
const scale = (size / 2) / range;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, size, size);
|
||||||
|
|
||||||
|
// Background
|
||||||
|
ctx.fillStyle = '#111';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, cx, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Range rings
|
||||||
|
ctx.strokeStyle = '#333';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let i = 1; i <= 4; i++) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, (cx / 4) * i, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crosshairs
|
||||||
|
ctx.strokeStyle = '#333';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, 0); ctx.lineTo(cx, size);
|
||||||
|
ctx.moveTo(0, cy); ctx.lineTo(size, cy);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Heading line (north indicator or player heading)
|
||||||
|
const headingRad = headingUp ? 0 : (-playerHeading * Math.PI / 180);
|
||||||
|
ctx.strokeStyle = '#666';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
const nhx = cx + Math.sin(headingRad) * cx * 0.9;
|
||||||
|
const nhy = cy - Math.cos(headingRad) * cx * 0.9;
|
||||||
|
ctx.moveTo(cx, cy);
|
||||||
|
ctx.lineTo(nhx, nhy);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// N label
|
||||||
|
ctx.fillStyle = '#888';
|
||||||
|
ctx.font = '10px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
const nlx = cx + Math.sin(headingRad) * (cx - 10);
|
||||||
|
const nly = cy - Math.cos(headingRad) * (cx - 10);
|
||||||
|
ctx.fillText('N', nlx, nly + 3);
|
||||||
|
|
||||||
|
// Rotation angle for heading-up mode (negate to counter-rotate world)
|
||||||
|
const rotAngle = headingUp ? (-playerHeading * Math.PI / 180) : 0;
|
||||||
|
|
||||||
|
// Draw objects
|
||||||
|
objects.forEach(obj => {
|
||||||
|
const dEW = obj.ew - playerEW;
|
||||||
|
const dNS = obj.ns - playerNS;
|
||||||
|
|
||||||
|
// Rotate if heading-up
|
||||||
|
let dx, dy;
|
||||||
|
if (rotAngle !== 0) {
|
||||||
|
const cosA = Math.cos(rotAngle);
|
||||||
|
const sinA = Math.sin(rotAngle);
|
||||||
|
dx = dEW * cosA - dNS * sinA;
|
||||||
|
dy = -(dEW * sinA + dNS * cosA);
|
||||||
|
} else {
|
||||||
|
dx = dEW;
|
||||||
|
dy = -dNS; // NS increases north, canvas Y increases down
|
||||||
|
}
|
||||||
|
|
||||||
|
const px = cx + dx * scale;
|
||||||
|
const py = cy + dy * scale;
|
||||||
|
|
||||||
|
// Clip to circle
|
||||||
|
const distFromCenter = Math.sqrt((px - cx) ** 2 + (py - cy) ** 2);
|
||||||
|
if (distFromCenter > cx - 4) return;
|
||||||
|
|
||||||
|
const color = RADAR_COLORS[obj.object_class] || '#999';
|
||||||
|
const dotSize = (obj.object_class === 'Monster' || obj.object_class === 'Player') ? 4 : 3;
|
||||||
|
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Label for players and portals
|
||||||
|
if (obj.object_class === 'Player' || obj.object_class === 'Portal') {
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.font = '9px monospace';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(obj.name, px + 6, py + 3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Player dot (center)
|
||||||
|
ctx.fillStyle = '#ffcc00';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, 5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#fff';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// ─── Update entity list ───
|
||||||
|
// Sort by distance
|
||||||
|
const withDist = objects.map(obj => {
|
||||||
|
const dEW = obj.ew - playerEW;
|
||||||
|
const dNS = obj.ns - playerNS;
|
||||||
|
// Convert coordinate delta to approximate meters (1 AC coord unit = 240 game units ≈ 240m)
|
||||||
|
const distCoord = Math.sqrt(dEW * dEW + dNS * dNS);
|
||||||
|
const distMeters = distCoord * 240;
|
||||||
|
|
||||||
|
// Compass direction
|
||||||
|
const angle = Math.atan2(dEW, dNS) * 180 / Math.PI;
|
||||||
|
const dir = compassDir(angle);
|
||||||
|
|
||||||
|
return { ...obj, distMeters, dir };
|
||||||
|
});
|
||||||
|
withDist.sort((a, b) => a.distMeters - b.distMeters);
|
||||||
|
|
||||||
|
// Rebuild list (simple approach — runs 1/sec so fine)
|
||||||
|
listBody.innerHTML = '';
|
||||||
|
withDist.forEach(obj => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'radar-entity-row';
|
||||||
|
|
||||||
|
const colorDot = document.createElement('span');
|
||||||
|
colorDot.className = 're-color';
|
||||||
|
colorDot.style.background = RADAR_COLORS[obj.object_class] || '#999';
|
||||||
|
row.appendChild(colorDot);
|
||||||
|
|
||||||
|
const nameSpan = document.createElement('span');
|
||||||
|
nameSpan.className = 're-name';
|
||||||
|
nameSpan.textContent = obj.name;
|
||||||
|
if (obj.health_pct !== null && obj.health_pct !== undefined) {
|
||||||
|
nameSpan.textContent += ` (${obj.health_pct}%)`;
|
||||||
|
}
|
||||||
|
row.appendChild(nameSpan);
|
||||||
|
|
||||||
|
const typeSpan = document.createElement('span');
|
||||||
|
typeSpan.className = 're-type';
|
||||||
|
typeSpan.textContent = obj.object_class;
|
||||||
|
row.appendChild(typeSpan);
|
||||||
|
|
||||||
|
const distSpan = document.createElement('span');
|
||||||
|
distSpan.className = 're-dist';
|
||||||
|
distSpan.textContent = obj.distMeters < 1000
|
||||||
|
? `${Math.round(obj.distMeters)}m`
|
||||||
|
: `${(obj.distMeters / 1000).toFixed(1)}km`;
|
||||||
|
row.appendChild(distSpan);
|
||||||
|
|
||||||
|
const dirSpan = document.createElement('span');
|
||||||
|
dirSpan.className = 're-dir';
|
||||||
|
dirSpan.textContent = obj.dir;
|
||||||
|
row.appendChild(dirSpan);
|
||||||
|
|
||||||
|
listBody.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function compassDir(angleDeg) {
|
||||||
|
// angleDeg: 0 = N, 90 = E, -90 = W, 180 = S
|
||||||
|
const a = ((angleDeg % 360) + 360) % 360;
|
||||||
|
const dirs = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
||||||
|
return dirs[Math.round(a / 45) % 8];
|
||||||
|
}
|
||||||
|
|
|
||||||
130
static/style.css
130
static/style.css
|
|
@ -525,7 +525,7 @@ body {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-window, .stats-window, .inventory-window, .character-window {
|
.chat-window, .stats-window, .inventory-window, .character-window, .radar-window {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
/* position window to start just right of the sidebar */
|
/* position window to start just right of the sidebar */
|
||||||
|
|
@ -2502,3 +2502,131 @@ table.ts-allegiance td:first-child {
|
||||||
border-top: 1px solid #5a4a24;
|
border-top: 1px solid #5a4a24;
|
||||||
border-bottom: 1px solid #5a4a24;
|
border-bottom: 1px solid #5a4a24;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Radar Window ─── */
|
||||||
|
|
||||||
|
.radar-window {
|
||||||
|
width: 360px;
|
||||||
|
height: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-window .window-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-controls label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-range-select {
|
||||||
|
background: #222;
|
||||||
|
color: #ccc;
|
||||||
|
border: 1px solid #444;
|
||||||
|
padding: 2px 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-canvas {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-entity-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-entity-header,
|
||||||
|
.radar-entity-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 6px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-entity-header {
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #888;
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-entity-row {
|
||||||
|
border-bottom: 1px solid #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-entity-row:hover {
|
||||||
|
background: #1a1a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.re-color {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.re-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.re-type {
|
||||||
|
width: 60px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.re-dist {
|
||||||
|
width: 45px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: right;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.re-dir {
|
||||||
|
width: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-btn {
|
||||||
|
background: #553388;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #774499;
|
||||||
|
padding: 1px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-btn:hover {
|
||||||
|
background: #664499;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue