feat(drone): add main visuals panel with map and contact list

- Sidebar inputs now use form-group/label pattern matching other modes
- Move map and contact list out of sidebar into a dedicated droneVisuals
  main panel (same pattern as tscm, spystations, etc.)
- droneVisuals: stats header (contacts / non-compliant / high-risk),
  left contact card panel, and full-height Leaflet map on the right
- Wire droneVisuals into switchMode display toggle and modesWithVisuals
  so the shared signal-feed output is hidden when drone mode is active
- Add invalidateMap() to force Leaflet to recalculate after the
  container becomes visible
- Stats now update both sidebar counts and main panel values

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
James Smith
2026-05-13 09:16:54 +01:00
parent 2475e5dd5a
commit 6523686aca
4 changed files with 145 additions and 35 deletions
+75 -6
View File
@@ -1,5 +1,80 @@
/* Drone Intelligence Styles */
/* ── Main visuals panel ── */
.drone-visuals-container {
display: none;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
background: var(--bg-primary);
}
.drone-visuals-header {
flex-shrink: 0;
padding: 10px 16px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.drone-visuals-stats {
display: flex;
gap: 24px;
}
.drone-vsstat {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.drone-vsstat-value {
font-size: 22px;
font-weight: 700;
font-family: var(--font-mono);
color: var(--text-primary);
line-height: 1;
}
.drone-vsstat-label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
}
.drone-visuals-body {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
}
.drone-contact-panel {
width: 300px;
flex-shrink: 0;
overflow-y: auto;
border-right: 1px solid var(--border-color);
padding: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.drone-main-map {
flex: 1;
min-width: 0;
}
.drone-empty-state {
padding: 24px 12px;
text-align: center;
font-size: 11px;
color: var(--text-dim);
line-height: 1.6;
}
.drone-vector-pills {
display: flex;
flex-wrap: wrap;
@@ -69,12 +144,6 @@
color: var(--accent-red);
}
.drone-map {
height: 280px;
border-radius: 4px;
border: 1px solid var(--border-color);
margin: 0 12px 12px;
}
.drone-marker-high-risk {
animation: dsc-distress-pulse 1.5s infinite;
+18 -9
View File
@@ -17,9 +17,9 @@
function _initMap() {
if (_map) return;
const mapEl = document.getElementById('droneMap');
const mapEl = document.getElementById('droneMainMap');
if (!mapEl || typeof L === 'undefined') return;
_map = L.map('droneMap', { zoomControl: true }).setView([20, 0], 2);
_map = L.map('droneMainMap', { zoomControl: true }).setView([20, 0], 2);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap',
maxZoom: 18,
@@ -64,7 +64,9 @@
function _upsertCard(contact) {
const listEl = document.getElementById('droneContactList');
const emptyEl = document.getElementById('droneContactEmpty');
if (!listEl) return;
if (emptyEl) emptyEl.style.display = 'none';
let card = document.getElementById('drone-card-' + contact.id);
if (!card) {
card = document.createElement('div');
@@ -80,8 +82,8 @@
const vectors = (contact.detection_vectors || []).map(function (v) {
return '<span class="drone-vector-pill active">' + v + '</span>';
}).join('');
const alt = contact.altitude_m != null ? contact.altitude_m.toFixed(0) + 'm' : '—';
const spd = contact.speed_ms != null ? contact.speed_ms.toFixed(1) + 'm/s' : '—';
const alt = contact.altitude_m != null ? contact.altitude_m.toFixed(0) + ' m' : '—';
const spd = contact.speed_ms != null ? contact.speed_ms.toFixed(1) + ' m/s' : '—';
card.innerHTML = [
'<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">',
' <span style="font-family:var(--font-mono); font-size:11px; color:var(--accent-cyan);">' + (contact.serial_number || contact.id) + '</span>',
@@ -138,10 +140,13 @@
.then(function (r) { return r.json(); })
.then(function (contacts) {
const nonCompliant = contacts.filter(function (c) { return !c.compliant; }).length;
const countEl = document.getElementById('droneContactCount');
const ncEl = document.getElementById('droneNonCompliantCount');
if (countEl) countEl.textContent = contacts.length;
if (ncEl) ncEl.textContent = nonCompliant;
const highRisk = contacts.filter(function (c) { return c.risk_level === 'high'; }).length;
const set = function (id, val) { const el = document.getElementById(id); if (el) el.textContent = val; };
set('droneContactCount', contacts.length);
set('droneNonCompliantCount', nonCompliant);
set('droneVsContacts', contacts.length);
set('droneVsNonCompliant', nonCompliant);
set('droneVsHighRisk', highRisk);
})
.catch(function () {});
}
@@ -201,5 +206,9 @@
});
}
window.DroneMode = { init: init, destroy: destroy };
function invalidateMap() {
if (_map) _map.invalidateSize();
}
window.DroneMode = { init: init, destroy: destroy, invalidateMap: invalidateMap };
})();