espilon-source/tools/c2/static/js/multilateration.js

332 lines
10 KiB
JavaScript

/**
* Trilateration visualization for ESPILON C2
* Renders scanner positions and target location on a 2D canvas
*/
class TrilaterationViz {
constructor(canvasId) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
// Coordinate system bounds (auto-adjusted based on data)
this.bounds = { minX: -2, maxX: 15, minY: -2, maxY: 15 };
this.padding = 40;
// Data
this.scanners = [];
this.target = null;
// Colors
this.colors = {
background: '#010409',
grid: '#21262d',
gridText: '#484f58',
scanner: '#58a6ff',
scannerCircle: 'rgba(88, 166, 255, 0.15)',
target: '#f85149',
targetGlow: 'rgba(248, 81, 73, 0.3)',
text: '#c9d1d9'
};
this.resize();
window.addEventListener('resize', () => this.resize());
}
resize() {
const rect = this.canvas.parentElement.getBoundingClientRect();
this.canvas.width = rect.width - 32; // Account for padding
this.canvas.height = 500;
this.draw();
}
// Convert world coordinates to canvas coordinates
worldToCanvas(x, y) {
const w = this.canvas.width - this.padding * 2;
const h = this.canvas.height - this.padding * 2;
const rangeX = this.bounds.maxX - this.bounds.minX;
const rangeY = this.bounds.maxY - this.bounds.minY;
return {
x: this.padding + ((x - this.bounds.minX) / rangeX) * w,
y: this.canvas.height - this.padding - ((y - this.bounds.minY) / rangeY) * h
};
}
// Convert distance to canvas pixels
distanceToPixels(distance) {
const w = this.canvas.width - this.padding * 2;
const rangeX = this.bounds.maxX - this.bounds.minX;
return (distance / rangeX) * w;
}
updateBounds() {
if (this.scanners.length === 0) {
this.bounds = { minX: -2, maxX: 15, minY: -2, maxY: 15 };
return;
}
let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity;
for (const s of this.scanners) {
minX = Math.min(minX, s.position.x);
maxX = Math.max(maxX, s.position.x);
minY = Math.min(minY, s.position.y);
maxY = Math.max(maxY, s.position.y);
}
if (this.target) {
minX = Math.min(minX, this.target.x);
maxX = Math.max(maxX, this.target.x);
minY = Math.min(minY, this.target.y);
maxY = Math.max(maxY, this.target.y);
}
// Add margin
const marginX = Math.max(2, (maxX - minX) * 0.2);
const marginY = Math.max(2, (maxY - minY) * 0.2);
this.bounds = {
minX: minX - marginX,
maxX: maxX + marginX,
minY: minY - marginY,
maxY: maxY + marginY
};
}
draw() {
const ctx = this.ctx;
const w = this.canvas.width;
const h = this.canvas.height;
// Clear
ctx.fillStyle = this.colors.background;
ctx.fillRect(0, 0, w, h);
// Draw grid
this.drawGrid();
// Draw scanner range circles
for (const scanner of this.scanners) {
if (scanner.estimated_distance) {
this.drawRangeCircle(scanner);
}
}
// Draw scanners
for (const scanner of this.scanners) {
this.drawScanner(scanner);
}
// Draw target
if (this.target) {
this.drawTarget();
}
}
drawGrid() {
const ctx = this.ctx;
ctx.strokeStyle = this.colors.grid;
ctx.lineWidth = 1;
ctx.font = '10px monospace';
ctx.fillStyle = this.colors.gridText;
// Determine grid spacing
const rangeX = this.bounds.maxX - this.bounds.minX;
const rangeY = this.bounds.maxY - this.bounds.minY;
const gridStep = Math.pow(10, Math.floor(Math.log10(Math.max(rangeX, rangeY) / 5)));
// Vertical lines
for (let x = Math.ceil(this.bounds.minX / gridStep) * gridStep; x <= this.bounds.maxX; x += gridStep) {
const p = this.worldToCanvas(x, 0);
ctx.beginPath();
ctx.moveTo(p.x, this.padding);
ctx.lineTo(p.x, this.canvas.height - this.padding);
ctx.stroke();
ctx.fillText(x.toFixed(1), p.x - 10, this.canvas.height - this.padding + 15);
}
// Horizontal lines
for (let y = Math.ceil(this.bounds.minY / gridStep) * gridStep; y <= this.bounds.maxY; y += gridStep) {
const p = this.worldToCanvas(0, y);
ctx.beginPath();
ctx.moveTo(this.padding, p.y);
ctx.lineTo(this.canvas.width - this.padding, p.y);
ctx.stroke();
ctx.fillText(y.toFixed(1), 5, p.y + 4);
}
}
drawRangeCircle(scanner) {
const ctx = this.ctx;
const pos = this.worldToCanvas(scanner.position.x, scanner.position.y);
const radius = this.distanceToPixels(scanner.estimated_distance);
ctx.beginPath();
ctx.arc(pos.x, pos.y, radius, 0, Math.PI * 2);
ctx.strokeStyle = this.colors.scannerCircle;
ctx.lineWidth = 2;
ctx.stroke();
}
drawScanner(scanner) {
const ctx = this.ctx;
const pos = this.worldToCanvas(scanner.position.x, scanner.position.y);
// Scanner dot
ctx.beginPath();
ctx.arc(pos.x, pos.y, 8, 0, Math.PI * 2);
ctx.fillStyle = this.colors.scanner;
ctx.fill();
// Label
ctx.font = '12px monospace';
ctx.fillStyle = this.colors.text;
ctx.textAlign = 'center';
ctx.fillText(scanner.id, pos.x, pos.y - 15);
// RSSI info
if (scanner.last_rssi !== null) {
ctx.font = '10px monospace';
ctx.fillStyle = this.colors.gridText;
ctx.fillText(`${scanner.last_rssi} dBm`, pos.x, pos.y + 20);
}
ctx.textAlign = 'left';
}
drawTarget() {
const ctx = this.ctx;
const pos = this.worldToCanvas(this.target.x, this.target.y);
// Glow effect
ctx.beginPath();
ctx.arc(pos.x, pos.y, 20, 0, Math.PI * 2);
ctx.fillStyle = this.colors.targetGlow;
ctx.fill();
// Cross marker
ctx.strokeStyle = this.colors.target;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(pos.x - 12, pos.y - 12);
ctx.lineTo(pos.x + 12, pos.y + 12);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(pos.x + 12, pos.y - 12);
ctx.lineTo(pos.x - 12, pos.y + 12);
ctx.stroke();
// Label
ctx.font = 'bold 12px monospace';
ctx.fillStyle = this.colors.target;
ctx.textAlign = 'center';
ctx.fillText('TARGET', pos.x, pos.y - 25);
ctx.textAlign = 'left';
}
update(state) {
this.scanners = state.scanners || [];
this.target = state.target?.position || null;
this.updateBounds();
this.draw();
}
}
// Initialize visualization
const viz = new TrilaterationViz('trilat-canvas');
// UI Update functions
function updateTargetInfo(target) {
if (target && target.position) {
document.getElementById('target-x').textContent = target.position.x.toFixed(2) + ' m';
document.getElementById('target-y').textContent = target.position.y.toFixed(2) + ' m';
document.getElementById('target-confidence').textContent = ((target.confidence || 0) * 100).toFixed(0) + '%';
document.getElementById('target-age').textContent = (target.age_seconds || 0).toFixed(1) + 's ago';
} else {
document.getElementById('target-x').textContent = '-';
document.getElementById('target-y').textContent = '-';
document.getElementById('target-confidence').textContent = '-';
document.getElementById('target-age').textContent = '-';
}
}
function updateScannerList(scanners) {
const list = document.getElementById('scanner-list');
document.getElementById('scanner-count').textContent = scanners.length;
if (scanners.length === 0) {
list.innerHTML = '<div class="empty" style="padding: 20px;"><p>No scanners active</p></div>';
return;
}
list.innerHTML = scanners.map(s => `
<div class="scanner-item">
<div class="scanner-id">${s.id}</div>
<div class="scanner-details">
Pos: (${s.position.x}, ${s.position.y}) |
RSSI: ${s.last_rssi !== null ? s.last_rssi + ' dBm' : '-'} |
Dist: ${s.estimated_distance !== null ? s.estimated_distance + 'm' : '-'}
</div>
</div>
`).join('');
}
function updateConfig(config) {
document.getElementById('config-rssi').value = config.rssi_at_1m;
document.getElementById('config-n').value = config.path_loss_n;
document.getElementById('config-smooth').value = config.smoothing_window;
}
// API functions
async function fetchState() {
try {
const res = await fetch('/api/multilat/state');
const state = await res.json();
viz.update(state);
updateTargetInfo(state.target);
updateScannerList(state.scanners);
if (state.config) {
updateConfig(state.config);
}
} catch (e) {
console.error('Failed to fetch trilateration state:', e);
}
}
async function saveConfig() {
const config = {
rssi_at_1m: parseFloat(document.getElementById('config-rssi').value),
path_loss_n: parseFloat(document.getElementById('config-n').value),
smoothing_window: parseInt(document.getElementById('config-smooth').value)
};
try {
await fetch('/api/multilat/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
console.log('Config saved');
} catch (e) {
console.error('Failed to save config:', e);
}
}
async function clearData() {
try {
await fetch('/api/multilat/clear', { method: 'POST' });
fetchState();
} catch (e) {
console.error('Failed to clear data:', e);
}
}
// Start polling
fetchState();
setInterval(fetchState, 2000);