mirror of
https://github.com/smittix/intercept.git
synced 2026-04-25 23:29:59 -07:00
Add proximity radar visualization and signal history heatmap
Backend: - Add device_key.py for stable device identification (identity > public MAC > fingerprint) - Add distance.py with DistanceEstimator class (path-loss formula, EMA smoothing, confidence scoring) - Add ring_buffer.py for time-windowed RSSI observation storage - Extend BTDeviceAggregate with proximity_band, estimated_distance_m, distance_confidence, rssi_ema - Add new API endpoints: /proximity/snapshot, /heatmap/data, /devices/<key>/timeseries - Update TSCM integration to include new proximity fields Frontend: - Add proximity-radar.js: SVG radar with concentric rings, device dots positioned by distance - Add timeline-heatmap.js: RSSI history grid with time buckets and color-coded signal strength - Update bluetooth.js to initialize and feed data to new components - Replace zone counters with radar visualization and zone summary - Add proximity-viz.css for component styling Tests: - Add test_bluetooth_proximity.py with unit tests for device key stability, EMA smoothing, distance estimation, band classification, and ring buffer functionality Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
369
static/js/components/proximity-radar.js
Normal file
369
static/js/components/proximity-radar.js
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Proximity Radar Component
|
||||
*
|
||||
* SVG-based circular radar visualization for Bluetooth device proximity.
|
||||
* Displays devices positioned by estimated distance with concentric rings
|
||||
* for proximity bands.
|
||||
*/
|
||||
|
||||
const ProximityRadar = (function() {
|
||||
'use strict';
|
||||
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
size: 280,
|
||||
padding: 20,
|
||||
centerRadius: 8,
|
||||
rings: [
|
||||
{ band: 'immediate', radius: 0.25, color: '#22c55e', label: '< 1m' },
|
||||
{ band: 'near', radius: 0.5, color: '#eab308', label: '1-3m' },
|
||||
{ band: 'far', radius: 0.85, color: '#ef4444', label: '3-10m' },
|
||||
],
|
||||
dotMinSize: 4,
|
||||
dotMaxSize: 12,
|
||||
pulseAnimationDuration: 2000,
|
||||
newDeviceThreshold: 30, // seconds
|
||||
};
|
||||
|
||||
// State
|
||||
let container = null;
|
||||
let svg = null;
|
||||
let devices = new Map();
|
||||
let isPaused = false;
|
||||
let activeFilter = null;
|
||||
let onDeviceClick = null;
|
||||
|
||||
/**
|
||||
* Initialize the radar component
|
||||
*/
|
||||
function init(containerId, options = {}) {
|
||||
container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
console.error('[ProximityRadar] Container not found:', containerId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.onDeviceClick) {
|
||||
onDeviceClick = options.onDeviceClick;
|
||||
}
|
||||
|
||||
createSVG();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the SVG radar structure
|
||||
*/
|
||||
function createSVG() {
|
||||
const size = CONFIG.size;
|
||||
const center = size / 2;
|
||||
|
||||
container.innerHTML = `
|
||||
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" class="proximity-radar-svg">
|
||||
<defs>
|
||||
<radialGradient id="radarGradient" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="rgba(0, 212, 255, 0.1)" />
|
||||
<stop offset="100%" stop-color="rgba(0, 212, 255, 0)" />
|
||||
</radialGradient>
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background gradient -->
|
||||
<circle cx="${center}" cy="${center}" r="${center - CONFIG.padding}"
|
||||
fill="url(#radarGradient)" />
|
||||
|
||||
<!-- Proximity rings -->
|
||||
<g class="radar-rings">
|
||||
${CONFIG.rings.map((ring, i) => {
|
||||
const r = ring.radius * (center - CONFIG.padding);
|
||||
return `
|
||||
<circle cx="${center}" cy="${center}" r="${r}"
|
||||
fill="none" stroke="${ring.color}" stroke-opacity="0.3"
|
||||
stroke-width="1" stroke-dasharray="4,4" />
|
||||
<text x="${center}" y="${center - r + 12}"
|
||||
text-anchor="middle" fill="${ring.color}" fill-opacity="0.6"
|
||||
font-size="9" font-family="monospace">${ring.label}</text>
|
||||
`;
|
||||
}).join('')}
|
||||
</g>
|
||||
|
||||
<!-- Sweep line (animated) -->
|
||||
<line class="radar-sweep" x1="${center}" y1="${center}"
|
||||
x2="${center}" y2="${CONFIG.padding}"
|
||||
stroke="rgba(0, 212, 255, 0.5)" stroke-width="1" />
|
||||
|
||||
<!-- Center point -->
|
||||
<circle cx="${center}" cy="${center}" r="${CONFIG.centerRadius}"
|
||||
fill="#00d4ff" filter="url(#glow)" />
|
||||
|
||||
<!-- Device dots container -->
|
||||
<g class="radar-devices"></g>
|
||||
|
||||
<!-- Legend -->
|
||||
<g class="radar-legend" transform="translate(${size - 70}, ${size - 55})">
|
||||
<text x="0" y="0" fill="#666" font-size="8">PROXIMITY</text>
|
||||
<text x="0" y="0" fill="#666" font-size="7" font-style="italic"
|
||||
transform="translate(0, 10)">(signal strength)</text>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
svg = container.querySelector('svg');
|
||||
|
||||
// Add sweep animation
|
||||
animateSweep();
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate the radar sweep line
|
||||
*/
|
||||
function animateSweep() {
|
||||
const sweepLine = svg.querySelector('.radar-sweep');
|
||||
if (!sweepLine) return;
|
||||
|
||||
let angle = 0;
|
||||
const center = CONFIG.size / 2;
|
||||
|
||||
function rotate() {
|
||||
if (isPaused) {
|
||||
requestAnimationFrame(rotate);
|
||||
return;
|
||||
}
|
||||
|
||||
angle = (angle + 1) % 360;
|
||||
const rad = (angle * Math.PI) / 180;
|
||||
const radius = center - CONFIG.padding;
|
||||
const x2 = center + Math.sin(rad) * radius;
|
||||
const y2 = center - Math.cos(rad) * radius;
|
||||
|
||||
sweepLine.setAttribute('x2', x2);
|
||||
sweepLine.setAttribute('y2', y2);
|
||||
|
||||
requestAnimationFrame(rotate);
|
||||
}
|
||||
|
||||
requestAnimationFrame(rotate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update devices on the radar
|
||||
*/
|
||||
function updateDevices(deviceList) {
|
||||
if (isPaused) return;
|
||||
|
||||
// Update device map
|
||||
deviceList.forEach(device => {
|
||||
devices.set(device.device_key, device);
|
||||
});
|
||||
|
||||
// Apply filter and render
|
||||
renderDevices();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render device dots on the radar
|
||||
*/
|
||||
function renderDevices() {
|
||||
const devicesGroup = svg.querySelector('.radar-devices');
|
||||
if (!devicesGroup) return;
|
||||
|
||||
const center = CONFIG.size / 2;
|
||||
const maxRadius = center - CONFIG.padding;
|
||||
|
||||
// Filter devices
|
||||
let visibleDevices = Array.from(devices.values());
|
||||
|
||||
if (activeFilter === 'newOnly') {
|
||||
visibleDevices = visibleDevices.filter(d => d.is_new || d.age_seconds < CONFIG.newDeviceThreshold);
|
||||
} else if (activeFilter === 'strongest') {
|
||||
visibleDevices = visibleDevices
|
||||
.filter(d => d.rssi_current != null)
|
||||
.sort((a, b) => (b.rssi_current || -100) - (a.rssi_current || -100))
|
||||
.slice(0, 10);
|
||||
} else if (activeFilter === 'unapproved') {
|
||||
visibleDevices = visibleDevices.filter(d => !d.in_baseline);
|
||||
}
|
||||
|
||||
// Build SVG for each device
|
||||
const dots = visibleDevices.map(device => {
|
||||
// Calculate position
|
||||
const { x, y, radius } = calculateDevicePosition(device, center, maxRadius);
|
||||
|
||||
// Calculate dot size based on confidence
|
||||
const confidence = device.distance_confidence || 0.5;
|
||||
const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence;
|
||||
|
||||
// Get color based on proximity band
|
||||
const color = getBandColor(device.proximity_band);
|
||||
|
||||
// Check if newly seen (pulse animation)
|
||||
const isNew = device.age_seconds < 5;
|
||||
const pulseClass = isNew ? 'radar-dot-pulse' : '';
|
||||
|
||||
return `
|
||||
<g class="radar-device ${pulseClass}" data-device-key="${escapeAttr(device.device_key)}"
|
||||
transform="translate(${x}, ${y})" style="cursor: pointer;">
|
||||
<circle r="${dotSize}" fill="${color}"
|
||||
fill-opacity="${0.4 + confidence * 0.5}"
|
||||
stroke="${color}" stroke-width="1" />
|
||||
${device.is_new ? `<circle r="${dotSize + 3}" fill="none" stroke="#3b82f6" stroke-width="1" stroke-dasharray="2,2" />` : ''}
|
||||
<title>${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)</title>
|
||||
</g>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
devicesGroup.innerHTML = dots;
|
||||
|
||||
// Attach click handlers
|
||||
devicesGroup.querySelectorAll('.radar-device').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
const deviceKey = el.getAttribute('data-device-key');
|
||||
if (onDeviceClick && deviceKey) {
|
||||
onDeviceClick(deviceKey);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate device position on radar
|
||||
*/
|
||||
function calculateDevicePosition(device, center, maxRadius) {
|
||||
// Calculate radius based on proximity band/distance
|
||||
let radiusRatio;
|
||||
const band = device.proximity_band || 'unknown';
|
||||
|
||||
if (device.estimated_distance_m != null) {
|
||||
// Use actual distance (log scale)
|
||||
const maxDistance = 15;
|
||||
radiusRatio = Math.min(1, Math.log10(device.estimated_distance_m + 1) / Math.log10(maxDistance + 1));
|
||||
} else {
|
||||
// Use band-based positioning
|
||||
switch (band) {
|
||||
case 'immediate': radiusRatio = 0.15; break;
|
||||
case 'near': radiusRatio = 0.4; break;
|
||||
case 'far': radiusRatio = 0.7; break;
|
||||
default: radiusRatio = 0.9; break;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate angle based on device key hash (stable positioning)
|
||||
const angle = hashToAngle(device.device_key || device.device_id);
|
||||
const radius = radiusRatio * maxRadius;
|
||||
|
||||
const x = center + Math.sin(angle) * radius;
|
||||
const y = center - Math.cos(angle) * radius;
|
||||
|
||||
return { x, y, radius };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash string to angle for stable positioning
|
||||
*/
|
||||
function hashToAngle(str) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) - hash) + str.charCodeAt(i);
|
||||
hash = hash & hash;
|
||||
}
|
||||
return (Math.abs(hash) % 360) * (Math.PI / 180);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for proximity band
|
||||
*/
|
||||
function getBandColor(band) {
|
||||
switch (band) {
|
||||
case 'immediate': return '#22c55e';
|
||||
case 'near': return '#eab308';
|
||||
case 'far': return '#ef4444';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set filter mode
|
||||
*/
|
||||
function setFilter(filter) {
|
||||
activeFilter = filter === activeFilter ? null : filter;
|
||||
renderDevices();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle pause state
|
||||
*/
|
||||
function setPaused(paused) {
|
||||
isPaused = paused;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all devices
|
||||
*/
|
||||
function clear() {
|
||||
devices.clear();
|
||||
renderDevices();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get zone counts
|
||||
*/
|
||||
function getZoneCounts() {
|
||||
const counts = { immediate: 0, near: 0, far: 0, unknown: 0 };
|
||||
devices.forEach(device => {
|
||||
const band = device.proximity_band || 'unknown';
|
||||
if (counts.hasOwnProperty(band)) {
|
||||
counts[band]++;
|
||||
} else {
|
||||
counts.unknown++;
|
||||
}
|
||||
});
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML for safe rendering
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(text);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape attribute value
|
||||
*/
|
||||
function escapeAttr(text) {
|
||||
if (!text) return '';
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init,
|
||||
updateDevices,
|
||||
setFilter,
|
||||
setPaused,
|
||||
clear,
|
||||
getZoneCounts,
|
||||
isPaused: () => isPaused,
|
||||
getFilter: () => activeFilter,
|
||||
};
|
||||
})();
|
||||
|
||||
// Export for module systems
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = ProximityRadar;
|
||||
}
|
||||
|
||||
window.ProximityRadar = ProximityRadar;
|
||||
409
static/js/components/timeline-heatmap.js
Normal file
409
static/js/components/timeline-heatmap.js
Normal file
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* Timeline Heatmap Component
|
||||
*
|
||||
* Displays RSSI signal history as a heatmap grid.
|
||||
* Y-axis: devices, X-axis: time buckets, Cell color: RSSI strength
|
||||
*/
|
||||
|
||||
const TimelineHeatmap = (function() {
|
||||
'use strict';
|
||||
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
cellWidth: 8,
|
||||
cellHeight: 20,
|
||||
labelWidth: 120,
|
||||
maxDevices: 20,
|
||||
refreshInterval: 5000,
|
||||
// RSSI color scale (green = strong, red = weak)
|
||||
colorScale: [
|
||||
{ rssi: -40, color: '#22c55e' }, // Strong - green
|
||||
{ rssi: -55, color: '#84cc16' }, // Good - lime
|
||||
{ rssi: -65, color: '#eab308' }, // Medium - yellow
|
||||
{ rssi: -75, color: '#f97316' }, // Weak - orange
|
||||
{ rssi: -90, color: '#ef4444' }, // Very weak - red
|
||||
],
|
||||
noDataColor: '#2a2a3e',
|
||||
};
|
||||
|
||||
// State
|
||||
let container = null;
|
||||
let contentEl = null;
|
||||
let controlsEl = null;
|
||||
let data = null;
|
||||
let isPaused = false;
|
||||
let refreshTimer = null;
|
||||
let selectedDeviceKey = null;
|
||||
let onDeviceSelect = null;
|
||||
|
||||
// Settings
|
||||
let settings = {
|
||||
windowMinutes: 10,
|
||||
bucketSeconds: 10,
|
||||
sortBy: 'recency',
|
||||
topN: 20,
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize the heatmap component
|
||||
*/
|
||||
function init(containerId, options = {}) {
|
||||
container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
console.error('[TimelineHeatmap] Container not found:', containerId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.onDeviceSelect) {
|
||||
onDeviceSelect = options.onDeviceSelect;
|
||||
}
|
||||
|
||||
// Merge options into settings
|
||||
Object.assign(settings, options);
|
||||
|
||||
createStructure();
|
||||
startAutoRefresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the heatmap DOM structure
|
||||
*/
|
||||
function createStructure() {
|
||||
container.innerHTML = `
|
||||
<div class="timeline-heatmap-controls">
|
||||
<div class="heatmap-control-group">
|
||||
<label>Window:</label>
|
||||
<select id="heatmapWindow" class="heatmap-select">
|
||||
<option value="10" ${settings.windowMinutes === 10 ? 'selected' : ''}>10 min</option>
|
||||
<option value="30" ${settings.windowMinutes === 30 ? 'selected' : ''}>30 min</option>
|
||||
<option value="60" ${settings.windowMinutes === 60 ? 'selected' : ''}>60 min</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="heatmap-control-group">
|
||||
<label>Bucket:</label>
|
||||
<select id="heatmapBucket" class="heatmap-select">
|
||||
<option value="10" ${settings.bucketSeconds === 10 ? 'selected' : ''}>10s</option>
|
||||
<option value="30" ${settings.bucketSeconds === 30 ? 'selected' : ''}>30s</option>
|
||||
<option value="60" ${settings.bucketSeconds === 60 ? 'selected' : ''}>60s</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="heatmap-control-group">
|
||||
<label>Sort:</label>
|
||||
<select id="heatmapSort" class="heatmap-select">
|
||||
<option value="recency" ${settings.sortBy === 'recency' ? 'selected' : ''}>Recent</option>
|
||||
<option value="strength" ${settings.sortBy === 'strength' ? 'selected' : ''}>Strength</option>
|
||||
<option value="activity" ${settings.sortBy === 'activity' ? 'selected' : ''}>Activity</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="heatmapPauseBtn" class="heatmap-btn ${isPaused ? 'active' : ''}">
|
||||
${isPaused ? 'Resume' : 'Pause'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="timeline-heatmap-content">
|
||||
<div class="heatmap-loading">Loading signal history...</div>
|
||||
</div>
|
||||
<div class="heatmap-legend">
|
||||
<span class="legend-label">Signal:</span>
|
||||
<span class="legend-item"><span class="legend-color" style="background: #22c55e;"></span>Strong</span>
|
||||
<span class="legend-item"><span class="legend-color" style="background: #eab308;"></span>Medium</span>
|
||||
<span class="legend-item"><span class="legend-color" style="background: #ef4444;"></span>Weak</span>
|
||||
<span class="legend-item"><span class="legend-color" style="background: ${CONFIG.noDataColor};"></span>No data</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
contentEl = container.querySelector('.timeline-heatmap-content');
|
||||
controlsEl = container.querySelector('.timeline-heatmap-controls');
|
||||
|
||||
// Attach event listeners
|
||||
attachEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach event listeners to controls
|
||||
*/
|
||||
function attachEventListeners() {
|
||||
const windowSelect = container.querySelector('#heatmapWindow');
|
||||
const bucketSelect = container.querySelector('#heatmapBucket');
|
||||
const sortSelect = container.querySelector('#heatmapSort');
|
||||
const pauseBtn = container.querySelector('#heatmapPauseBtn');
|
||||
|
||||
windowSelect?.addEventListener('change', (e) => {
|
||||
settings.windowMinutes = parseInt(e.target.value, 10);
|
||||
refresh();
|
||||
});
|
||||
|
||||
bucketSelect?.addEventListener('change', (e) => {
|
||||
settings.bucketSeconds = parseInt(e.target.value, 10);
|
||||
refresh();
|
||||
});
|
||||
|
||||
sortSelect?.addEventListener('change', (e) => {
|
||||
settings.sortBy = e.target.value;
|
||||
refresh();
|
||||
});
|
||||
|
||||
pauseBtn?.addEventListener('click', () => {
|
||||
isPaused = !isPaused;
|
||||
pauseBtn.textContent = isPaused ? 'Resume' : 'Pause';
|
||||
pauseBtn.classList.toggle('active', isPaused);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto-refresh timer
|
||||
*/
|
||||
function startAutoRefresh() {
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
|
||||
refreshTimer = setInterval(() => {
|
||||
if (!isPaused) {
|
||||
refresh();
|
||||
}
|
||||
}, CONFIG.refreshInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and render heatmap data
|
||||
*/
|
||||
async function refresh() {
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
top_n: settings.topN,
|
||||
window_minutes: settings.windowMinutes,
|
||||
bucket_seconds: settings.bucketSeconds,
|
||||
sort_by: settings.sortBy,
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/bluetooth/heatmap/data?${params}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch heatmap data');
|
||||
|
||||
data = await response.json();
|
||||
render();
|
||||
} catch (err) {
|
||||
console.error('[TimelineHeatmap] Refresh error:', err);
|
||||
contentEl.innerHTML = '<div class="heatmap-error">Failed to load data</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the heatmap grid
|
||||
*/
|
||||
function render() {
|
||||
if (!data || !data.devices || data.devices.length === 0) {
|
||||
contentEl.innerHTML = '<div class="heatmap-empty">No signal history available yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate time buckets
|
||||
const windowMs = settings.windowMinutes * 60 * 1000;
|
||||
const bucketMs = settings.bucketSeconds * 1000;
|
||||
const numBuckets = Math.ceil(windowMs / bucketMs);
|
||||
const now = new Date();
|
||||
|
||||
// Generate time labels
|
||||
const timeLabels = [];
|
||||
for (let i = 0; i < numBuckets; i++) {
|
||||
const time = new Date(now.getTime() - (numBuckets - 1 - i) * bucketMs);
|
||||
if (i % Math.ceil(numBuckets / 6) === 0) {
|
||||
timeLabels.push(time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
|
||||
} else {
|
||||
timeLabels.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// Build heatmap HTML
|
||||
let html = '<div class="heatmap-grid">';
|
||||
|
||||
// Time axis header
|
||||
html += `<div class="heatmap-row heatmap-header">
|
||||
<div class="heatmap-label"></div>
|
||||
<div class="heatmap-cells">
|
||||
${timeLabels.map(label =>
|
||||
`<div class="heatmap-time-label" style="width: ${CONFIG.cellWidth}px;">${label}</div>`
|
||||
).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Device rows
|
||||
data.devices.forEach(device => {
|
||||
const isSelected = device.device_key === selectedDeviceKey;
|
||||
const rowClass = isSelected ? 'heatmap-row selected' : 'heatmap-row';
|
||||
|
||||
// Create lookup for timeseries data
|
||||
const tsLookup = new Map();
|
||||
device.timeseries.forEach(point => {
|
||||
const ts = new Date(point.timestamp).getTime();
|
||||
tsLookup.set(ts, point.rssi);
|
||||
});
|
||||
|
||||
// Generate cells for each time bucket
|
||||
const cells = [];
|
||||
for (let i = 0; i < numBuckets; i++) {
|
||||
const bucketTime = new Date(now.getTime() - (numBuckets - 1 - i) * bucketMs);
|
||||
const bucketKey = Math.floor(bucketTime.getTime() / bucketMs) * bucketMs;
|
||||
|
||||
// Find closest timestamp in data
|
||||
let rssi = null;
|
||||
const tolerance = bucketMs;
|
||||
tsLookup.forEach((val, ts) => {
|
||||
if (Math.abs(ts - bucketKey) < tolerance) {
|
||||
rssi = val;
|
||||
}
|
||||
});
|
||||
|
||||
const color = rssi !== null ? getRssiColor(rssi) : CONFIG.noDataColor;
|
||||
const title = rssi !== null ? `${rssi} dBm` : 'No data';
|
||||
|
||||
cells.push(`<div class="heatmap-cell" style="width: ${CONFIG.cellWidth}px; height: ${CONFIG.cellHeight}px; background: ${color};" title="${title}"></div>`);
|
||||
}
|
||||
|
||||
const displayName = device.name || formatAddress(device.address) || device.device_key.substring(0, 12);
|
||||
const rssiDisplay = device.rssi_ema != null ? `${Math.round(device.rssi_ema)} dBm` : '--';
|
||||
|
||||
html += `
|
||||
<div class="${rowClass}" data-device-key="${escapeAttr(device.device_key)}">
|
||||
<div class="heatmap-label" title="${escapeHtml(device.name || device.address || '')}">
|
||||
<span class="device-name">${escapeHtml(displayName)}</span>
|
||||
<span class="device-rssi">${rssiDisplay}</span>
|
||||
</div>
|
||||
<div class="heatmap-cells">${cells.join('')}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
contentEl.innerHTML = html;
|
||||
|
||||
// Attach row click handlers
|
||||
contentEl.querySelectorAll('.heatmap-row:not(.heatmap-header)').forEach(row => {
|
||||
row.addEventListener('click', () => {
|
||||
const deviceKey = row.getAttribute('data-device-key');
|
||||
selectDevice(deviceKey);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for RSSI value
|
||||
*/
|
||||
function getRssiColor(rssi) {
|
||||
const scale = CONFIG.colorScale;
|
||||
|
||||
// Find the appropriate color from scale
|
||||
for (let i = 0; i < scale.length; i++) {
|
||||
if (rssi >= scale[i].rssi) {
|
||||
return scale[i].color;
|
||||
}
|
||||
}
|
||||
return scale[scale.length - 1].color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format MAC address for display
|
||||
*/
|
||||
function formatAddress(address) {
|
||||
if (!address) return null;
|
||||
const parts = address.split(':');
|
||||
if (parts.length === 6) {
|
||||
return `${parts[0]}:${parts[1]}:..${parts[5]}`;
|
||||
}
|
||||
return address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a device row
|
||||
*/
|
||||
function selectDevice(deviceKey) {
|
||||
selectedDeviceKey = deviceKey === selectedDeviceKey ? null : deviceKey;
|
||||
|
||||
// Update row highlighting
|
||||
contentEl.querySelectorAll('.heatmap-row').forEach(row => {
|
||||
const isSelected = row.getAttribute('data-device-key') === selectedDeviceKey;
|
||||
row.classList.toggle('selected', isSelected);
|
||||
});
|
||||
|
||||
// Callback
|
||||
if (onDeviceSelect && selectedDeviceKey) {
|
||||
const device = data?.devices?.find(d => d.device_key === selectedDeviceKey);
|
||||
onDeviceSelect(selectedDeviceKey, device);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update with new data directly (for SSE integration)
|
||||
*/
|
||||
function updateData(newData) {
|
||||
if (isPaused) return;
|
||||
data = newData;
|
||||
render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set paused state
|
||||
*/
|
||||
function setPaused(paused) {
|
||||
isPaused = paused;
|
||||
const pauseBtn = container?.querySelector('#heatmapPauseBtn');
|
||||
if (pauseBtn) {
|
||||
pauseBtn.textContent = isPaused ? 'Resume' : 'Pause';
|
||||
pauseBtn.classList.toggle('active', isPaused);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the component
|
||||
*/
|
||||
function destroy() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML for safe rendering
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(text);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape attribute value
|
||||
*/
|
||||
function escapeAttr(text) {
|
||||
if (!text) return '';
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init,
|
||||
refresh,
|
||||
updateData,
|
||||
setPaused,
|
||||
destroy,
|
||||
selectDevice,
|
||||
getSelectedDevice: () => selectedDeviceKey,
|
||||
isPaused: () => isPaused,
|
||||
};
|
||||
})();
|
||||
|
||||
// Export for module systems
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = TimelineHeatmap;
|
||||
}
|
||||
|
||||
window.TimelineHeatmap = TimelineHeatmap;
|
||||
Reference in New Issue
Block a user