/**
* 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 = `
Loading signal history...
Signal:
Strong
Medium
Weak
No data
`;
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 = 'Failed to load data
';
}
}
/**
* Render the heatmap grid
*/
function render() {
if (!data || !data.devices || data.devices.length === 0) {
contentEl.innerHTML = 'No signal history available yet
';
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 = '';
// Time axis header
html += ``;
// 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(`
`);
}
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 += `
${escapeHtml(displayName)}
${cells.join('')}
`;
});
html += '
';
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, '>');
}
// 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;