/**
* Signal Cards Component
* JavaScript utilities for creating and managing signal cards
* Used across: Pager, APRS, Sensors, and other signal-based modes
*/
const SignalCards = (function() {
'use strict';
// ==========================================================================
// Signal Strength Classification
// Translates RSSI values to confidence-safe, client-facing language
// ==========================================================================
const SignalClassification = {
// RSSI thresholds (dBm) - upper bounds
THRESHOLDS: {
MINIMAL: -85,
WEAK: -70,
MODERATE: -55,
STRONG: -40
// VERY_STRONG: > -40
},
// Signal strength metadata
STRENGTH_INFO: {
minimal: {
label: 'Minimal',
description: 'Near minimum observable level',
interpretation: 'may represent background activity or a distant source',
confidence: 'low',
color: '#888888',
icon: 'signal-0',
bars: 1
},
weak: {
label: 'Weak',
description: 'Low-level signal present',
interpretation: 'possibly distant or partially obstructed',
confidence: 'low',
color: '#6baed6',
icon: 'signal-1',
bars: 2
},
moderate: {
label: 'Moderate',
description: 'Consistent signal presence',
interpretation: 'likely in proximity',
confidence: 'medium',
color: '#3182bd',
icon: 'signal-2',
bars: 3
},
strong: {
label: 'Strong',
description: 'Clear, consistent signal',
interpretation: 'suggests relatively close proximity',
confidence: 'medium',
color: '#fd8d3c',
icon: 'signal-3',
bars: 4
},
very_strong: {
label: 'Very Strong',
description: 'Elevated signal level',
interpretation: 'consistent with a nearby source',
confidence: 'high',
color: '#e6550d',
icon: 'signal-4',
bars: 5
}
},
// Duration thresholds (seconds)
DURATION_THRESHOLDS: {
TRANSIENT: 5,
SHORT: 30,
SUSTAINED: 120
// PERSISTENT: > 120
},
DURATION_INFO: {
transient: {
label: 'Transient',
modifier: 'observed briefly',
confidence_impact: 'limits assessment confidence'
},
short: {
label: 'Short-duration',
modifier: 'observed for a short period',
confidence_impact: 'provides limited confidence'
},
sustained: {
label: 'Sustained',
modifier: 'observed over sustained period',
confidence_impact: 'supports assessment confidence'
},
persistent: {
label: 'Persistent',
modifier: 'continuously observed',
confidence_impact: 'strengthens assessment confidence'
}
},
/**
* Classify RSSI value into qualitative signal strength
*/
classifyStrength(rssi) {
if (rssi === null || rssi === undefined || isNaN(rssi)) {
return 'minimal';
}
const val = parseFloat(rssi);
if (val <= -85) return 'minimal';
if (val <= -70) return 'weak';
if (val <= -55) return 'moderate';
if (val <= -40) return 'strong';
return 'very_strong';
},
/**
* Classify detection duration
*/
classifyDuration(seconds) {
if (seconds === null || seconds === undefined || seconds < 0) {
return 'transient';
}
const val = parseFloat(seconds);
if (val < 5) return 'transient';
if (val < 30) return 'short';
if (val < 120) return 'sustained';
return 'persistent';
},
/**
* Get full signal strength info
*/
getStrengthInfo(rssi) {
const strength = this.classifyStrength(rssi);
return {
strength,
rssi,
...this.STRENGTH_INFO[strength]
};
},
/**
* Get full duration info
*/
getDurationInfo(seconds) {
const duration = this.classifyDuration(seconds);
return {
duration,
seconds,
...this.DURATION_INFO[duration]
};
},
/**
* Calculate overall confidence from signal + duration + observations
*/
calculateConfidence(rssi, durationSeconds, observationCount = 1) {
let score = 0;
const strength = this.classifyStrength(rssi);
const duration = this.classifyDuration(durationSeconds);
// Signal strength contribution
if (strength === 'strong' || strength === 'very_strong') score += 2;
else if (strength === 'moderate') score += 1;
// Duration contribution
if (duration === 'persistent') score += 2;
else if (duration === 'sustained') score += 1;
// Observation count contribution
if (observationCount >= 5) score += 2;
else if (observationCount >= 3) score += 1;
// Map to confidence level
if (score >= 5) return 'high';
if (score >= 3) return 'medium';
return 'low';
},
/**
* Generate hedged summary statement
*/
generateSummary(rssi, durationSeconds, observationCount = 1) {
const strengthInfo = this.getStrengthInfo(rssi);
const durationInfo = this.getDurationInfo(durationSeconds);
const confidence = this.calculateConfidence(rssi, durationSeconds, observationCount);
if (confidence === 'high') {
return `${strengthInfo.label}, ${durationInfo.label.toLowerCase()} signal with characteristics that suggest a transmitting device may be nearby`;
} else if (confidence === 'medium') {
return `${strengthInfo.label}, ${durationInfo.label.toLowerCase()} signal that may indicate nearby device activity`;
} else {
return `${durationInfo.modifier.charAt(0).toUpperCase() + durationInfo.modifier.slice(1)} ${strengthInfo.label.toLowerCase()} signal consistent with possible nearby device activity`;
}
},
/**
* Generate interpretation with hedging
*/
generateInterpretation(rssi, durationSeconds, observationCount = 1) {
const strengthInfo = this.getStrengthInfo(rssi);
const confidence = this.calculateConfidence(rssi, durationSeconds, observationCount);
const base = strengthInfo.interpretation;
if (confidence === 'high') {
return `Signal characteristics suggest ${base}`;
} else if (confidence === 'medium') {
return `Observed pattern may indicate ${base}`;
} else {
return `With limited data, this signal may represent ${base} or environmental factors`;
}
},
/**
* Estimate range from RSSI (with heavy caveats)
*/
estimateRange(rssi) {
if (rssi === null || rssi === undefined) {
return { estimate: 'Unknown', disclaimer: 'Insufficient signal data' };
}
const val = parseFloat(rssi);
let estimate, rangeMin, rangeMax;
if (val > -40) {
estimate = '< 3 meters';
rangeMin = 0; rangeMax = 3;
} else if (val > -55) {
estimate = '3-10 meters';
rangeMin = 3; rangeMax = 10;
} else if (val > -70) {
estimate = '5-20 meters';
rangeMin = 5; rangeMax = 20;
} else if (val > -85) {
estimate = '10-50 meters';
rangeMin = 10; rangeMax = 50;
} else {
estimate = '> 30 meters or heavily obstructed';
rangeMin = 30; rangeMax = null;
}
return {
estimate,
rangeMin,
rangeMax,
disclaimer: 'Range estimates are approximate and influenced by physical obstructions, interference, and transmitter power'
};
}
};
// Address tracking for new/repeated detection
const addressHistory = {
pager: new Map(), // address -> { count, firstSeen, lastSeen }
aprs: new Map(), // callsign -> { count, firstSeen, lastSeen }
sensor: new Map(), // id -> { count, firstSeen, lastSeen }
acars: new Map(), // flight -> { count, firstSeen, lastSeen }
ais: new Map(), // mmsi -> { count, firstSeen, lastSeen }
meter: new Map() // meter id -> { count, firstSeen, lastSeen }
};
// Threshold for "repeated" status (messages from same source)
const REPEATED_THRESHOLD = 3;
// Time window for "burst" detection (ms)
const BURST_WINDOW = 60000; // 1 minute
const BURST_THRESHOLD = 5; // 5+ messages in window = burst
// Store for managing cards and state
const state = {
cards: new Map(),
filters: {
status: 'all',
protocol: 'all',
msgType: 'all',
search: ''
},
counts: {
all: 0,
emergency: 0,
new: 0,
burst: 0,
repeated: 0,
baseline: 0
}
};
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text) {
if (text === null || text === undefined) return '';
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}
/**
* Format timestamp to relative time
*/
function formatRelativeTime(timestamp) {
if (!timestamp) return '';
let date = new Date(timestamp);
// Handle time-only strings like "HH:MM:SS" (from pager/sensor backends)
if (isNaN(date.getTime()) && /^\d{1,2}:\d{2}(:\d{2})?$/.test(timestamp)) {
const today = new Date();
date = new Date(today.toDateString() + ' ' + timestamp);
}
if (isNaN(date.getTime())) return timestamp;
const now = new Date();
const diff = Math.floor((now - date) / 1000);
if (diff < 60) return 'Just now';
if (diff < 3600) return Math.floor(diff / 60) + ' min ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return date.toLocaleDateString();
}
/**
* Track an address/identifier and return its status
*/
function trackAddress(type, identifier) {
const history = addressHistory[type];
if (!history) return { isNew: true, count: 1 };
const now = Date.now();
const existing = history.get(identifier);
if (!existing) {
// First time seeing this address
history.set(identifier, {
count: 1,
firstSeen: now,
lastSeen: now,
recentTimestamps: [now]
});
return { isNew: true, count: 1, isBurst: false };
}
// Update existing record
existing.count++;
existing.lastSeen = now;
// Track recent timestamps for burst detection
existing.recentTimestamps = existing.recentTimestamps || [];
existing.recentTimestamps.push(now);
// Clean old timestamps outside burst window
existing.recentTimestamps = existing.recentTimestamps.filter(
ts => (now - ts) < BURST_WINDOW
);
const isBurst = existing.recentTimestamps.length >= BURST_THRESHOLD;
const isRepeated = existing.count >= REPEATED_THRESHOLD;
return {
isNew: false,
count: existing.count,
isBurst: isBurst,
isRepeated: isRepeated,
firstSeen: existing.firstSeen
};
}
/**
* Get address stats without updating
*/
function getAddressStats(type, identifier) {
const history = addressHistory[type];
if (!history) return null;
return history.get(identifier) || null;
}
/**
* Clear address history (e.g., on session reset)
*/
function clearAddressHistory(type) {
if (type) {
if (addressHistory[type]) {
addressHistory[type].clear();
}
} else {
Object.keys(addressHistory).forEach(key => {
addressHistory[key].clear();
});
}
}
/**
* Determine signal status based on message data and tracking
*/
function determineStatus(msg, trackingType = 'pager') {
// Check for emergency indicators first
if (msg.emergency ||
(msg.message && /emergency|distress|mayday|sos|911|help/i.test(msg.message))) {
return 'emergency';
}
// Get identifier based on message type
let identifier;
switch (trackingType) {
case 'pager':
identifier = msg.address;
break;
case 'aprs':
identifier = msg.callsign || msg.source;
break;
case 'sensor':
identifier = msg.id || msg.sensor_id;
break;
case 'acars':
identifier = msg.flight || msg.tail;
break;
case 'ais':
identifier = msg.mmsi;
break;
default:
identifier = msg.address || msg.id;
}
if (!identifier) {
return 'baseline';
}
// Track and get status
const stats = trackAddress(trackingType, identifier);
if (stats.isNew) {
return 'new';
}
if (stats.isBurst) {
return 'burst';
}
if (stats.isRepeated) {
return 'repeated';
}
return 'baseline';
}
/**
* Get protocol class name
*/
function getProtoClass(protocol) {
if (!protocol) return '';
const proto = protocol.toLowerCase();
if (proto.includes('pocsag')) return 'pocsag';
if (proto.includes('flex')) return 'flex';
if (proto.includes('aprs')) return 'aprs';
if (proto.includes('ais')) return 'ais';
if (proto.includes('acars')) return 'acars';
return '';
}
/**
* Check if message content is numeric
*/
function isNumericContent(message) {
if (!message) return false;
return /^[0-9\s\-\*\#U]+$/.test(message);
}
/**
* Create signal strength indicator HTML
* Shows bars + label + optional tooltip with interpretation
*/
function createSignalIndicator(rssi, options = {}) {
if (rssi === null || rssi === undefined) return '';
const info = SignalClassification.getStrengthInfo(rssi);
const showLabel = options.showLabel !== false;
const showTooltip = options.showTooltip !== false;
const compact = options.compact === true;
// Create signal bars SVG
const bars = info.bars;
const barsSvg = `
`;
// Build tooltip content
let tooltipContent = '';
if (showTooltip) {
const rangeEst = SignalClassification.estimateRange(rssi);
tooltipContent = `
${info.label} signal (${rssi} dBm)
${info.description}
Est. range: ${rangeEst.estimate}
Confidence: ${info.confidence}
`.trim();
}
// Determine CSS class based on confidence
const confidenceClass = `signal-confidence-${info.confidence}`;
if (compact) {
return `
${barsSvg}
`;
}
return `
${barsSvg}
${showLabel ? `${info.label} ` : ''}
`;
}
/**
* Create detailed signal assessment panel for advanced details
*/
function createSignalAssessmentPanel(rssi, durationSeconds, observationCount = 1) {
if (rssi === null || rssi === undefined) return '';
const strengthInfo = SignalClassification.getStrengthInfo(rssi);
const durationInfo = SignalClassification.getDurationInfo(durationSeconds);
const confidence = SignalClassification.calculateConfidence(rssi, durationSeconds, observationCount);
const rangeEst = SignalClassification.estimateRange(rssi);
const interpretation = SignalClassification.generateInterpretation(rssi, durationSeconds, observationCount);
return `
Signal Assessment
${createSignalIndicator(rssi, { compact: false, showTooltip: false })}
${escapeHtml(interpretation)}
Signal Strength
${strengthInfo.label} (${rssi} dBm)
Detection
${durationInfo.label}
Est. Range
${rangeEst.estimate}
Confidence
${confidence.charAt(0).toUpperCase() + confidence.slice(1)}
Note: ${rangeEst.disclaimer}
`;
}
/**
* Get message type label
*/
function getMsgTypeLabel(msg) {
if (msg.msg_type) return msg.msg_type;
if (msg.message === '[Tone Only]') return 'Tone';
if (isNumericContent(msg.message)) return 'Numeric';
return 'Alpha';
}
/**
* Create a pager message card
*/
function createPagerCard(msg, options = {}) {
const status = options.status || determineStatus(msg, 'pager');
const protoClass = getProtoClass(msg.protocol);
const isNumeric = isNumericContent(msg.message);
const relativeTime = formatRelativeTime(msg.timestamp);
const isToneOnly = msg.message === '[Tone Only]' || msg.msg_type === 'Tone';
const msgType = getMsgTypeLabel(msg);
const card = document.createElement('article');
card.className = 'signal-card signal-card-clickable';
card.dataset.status = status;
card.dataset.type = 'message';
card.dataset.protocol = protoClass;
card.dataset.msgType = msgType.toLowerCase();
if (msg.address) card.dataset.address = msg.address;
// Store message data for dialog
card.dataset.msgData = JSON.stringify(msg);
// Get address stats for display
const stats = getAddressStats('pager', msg.address);
const seenCount = stats ? stats.count : 1;
card.innerHTML = `
${escapeHtml(msgType)}
${seenCount > 1 ? `×${seenCount} ` : ''}
${escapeHtml(relativeTime)}
${escapeHtml(msg.message || '[No content]')}
${!isToneOnly ? `Copy ` : ''}
Mute
`;
// Add click handler to open details dialog
card.addEventListener('click', () => {
showSignalDetails(card);
});
return card;
}
/**
* Create an APRS message card
*/
function createAprsCard(msg, options = {}) {
const status = options.status || determineStatus(msg, 'aprs');
const relativeTime = formatRelativeTime(msg.timestamp);
const hasPosition = msg.latitude && msg.longitude;
const card = document.createElement('article');
card.className = 'signal-card';
if (options.compact) card.classList.add('signal-card-compact');
card.dataset.status = status;
card.dataset.type = 'aprs';
card.dataset.protocol = 'aprs';
if (msg.callsign) card.dataset.callsign = msg.callsign;
// Determine APRS message type from packet_type or message content
let aprsType = msg.packet_type || 'position';
if (msg.weather) aprsType = 'weather';
else if (msg.telemetry) aprsType = 'telemetry';
else if (msg.message) aprsType = 'message';
else if (msg.status) aprsType = 'status';
card.dataset.packetType = aprsType.toLowerCase();
// Get stats
const stats = getAddressStats('aprs', msg.callsign);
const seenCount = stats ? stats.count : 1;
card.innerHTML = `
${aprsType.charAt(0).toUpperCase() + aprsType.slice(1)}
${msg.symbol ? `${escapeHtml(msg.symbol)} ` : ''}
${msg.distance !== null && msg.distance !== undefined ? `${msg.distance.toFixed(1)} mi ` : ''}
${seenCount > 1 ? `×${seenCount} ` : ''}
${escapeHtml(relativeTime)}
${msg.comment || msg.status || msg.message ? `
${escapeHtml(msg.comment || msg.status || msg.message)}
` : ''}
${msg.weather ? `
${msg.weather.temp ? `Temp: ${msg.weather.temp}°F ` : ''}
${msg.weather.humidity ? `Humidity: ${msg.weather.humidity}% ` : ''}
${msg.weather.wind_speed ? `Wind: ${msg.weather.wind_speed}mph ` : ''}
${msg.weather.wind_dir ? `from ${msg.weather.wind_dir}° ` : ''}
` : ''}
${hasPosition ? `
${msg.latitude.toFixed(4)}°, ${msg.longitude.toFixed(4)}°
` : ''}
Station Details
Callsign
${escapeHtml(msg.callsign)}
${msg.path ? `
Path
${escapeHtml(msg.path)}
` : ''}
${hasPosition ? `
Position
${msg.latitude.toFixed(5)}°, ${msg.longitude.toFixed(5)}°
` : ''}
${msg.altitude ? `
Altitude
${msg.altitude} ft
` : ''}
${msg.speed ? `
Speed
${msg.speed} mph
` : ''}
${msg.course ? `
Course
${msg.course}°
` : ''}
Seen
${seenCount} time${seenCount > 1 ? 's' : ''}
${msg.raw ? `
Raw Packet
${escapeHtml(msg.raw)}
` : ''}
`;
return card;
}
/**
* Create a sensor (433MHz) message card
*/
function createSensorCard(msg, options = {}) {
const status = options.status || determineStatus(msg, 'sensor');
const relativeTime = formatRelativeTime(msg.timestamp);
const card = document.createElement('article');
card.className = 'signal-card signal-card-clickable';
card.dataset.status = status;
card.dataset.type = 'sensor';
card.dataset.protocol = msg.model || 'unknown';
if (msg.id) card.dataset.sensorId = msg.id;
// Store message data for dialog
card.dataset.msgData = JSON.stringify(msg);
// Get stats
const stats = getAddressStats('sensor', msg.id);
const seenCount = stats ? stats.count : 1;
// Get signal strength if available (rtl_433 uses 'snr' for signal-to-noise ratio)
const rssi = msg.rssi || msg.signal_strength || msg.snr || msg.noise || null;
const signalIndicator = rssi !== null
? createSignalIndicator(rssi, { compact: true })
: '-- ';
// Signal type guessing based on frequency
let signalGuessBadge = '';
if (msg.frequency && typeof SignalGuess !== 'undefined') {
const frequencyHz = parseFloat(msg.frequency) * 1_000_000; // Convert MHz to Hz
const signalGuess = SignalGuess.guessSignalType({
frequency_hz: frequencyHz,
modulation: msg.modulation || null,
bandwidth_hz: msg.bandwidth ? parseFloat(msg.bandwidth) * 1000 : null,
rssi_dbm: rssi,
region: 'UK/EU'
});
// Create compact badge for header
if (signalGuess && signalGuess.primary_label !== 'Unknown Signal') {
signalGuessBadge = SignalGuess.createCompactBadge(signalGuess).outerHTML;
}
}
card.innerHTML = `
${msg.channel ? `Ch ${msg.channel} ` : ''}
${seenCount > 1 ? `×${seenCount} ` : ''}
${escapeHtml(relativeTime)}
${msg.temperature !== undefined ? `
Temp
${msg.temperature}°${msg.temperature_unit || 'F'}
` : ''}
${msg.humidity !== undefined ? `
Humidity
${msg.humidity}%
` : ''}
${msg.battery !== undefined ? `
Battery
${msg.battery}
` : ''}
${msg.pressure !== undefined ? `
Pressure
${msg.pressure} ${msg.pressure_unit || 'hPa'}
` : ''}
${msg.wind_speed !== undefined ? `
Wind
${msg.wind_speed} ${msg.wind_unit || 'mph'}
` : ''}
${msg.rain !== undefined ? `
Rain
${msg.rain} ${msg.rain_unit || 'mm'}
` : ''}
${msg.state !== undefined ? `
State
${escapeHtml(msg.state)}
` : ''}
Mute
`;
// Add click handler to open details dialog
card.addEventListener('click', () => {
showSignalDetails(card);
});
return card;
}
/**
* Create an ACARS message card
*/
function createAcarsCard(msg, options = {}) {
const status = options.status || determineStatus(msg, 'acars');
const relativeTime = formatRelativeTime(msg.timestamp);
const card = document.createElement('article');
card.className = 'signal-card';
card.dataset.status = status;
card.dataset.type = 'acars';
card.dataset.protocol = 'acars';
if (msg.flight) card.dataset.flight = msg.flight;
// Get stats
const stats = getAddressStats('acars', msg.flight || msg.tail);
const seenCount = stats ? stats.count : 1;
card.innerHTML = `
${msg.label ? `${escapeHtml(msg.label)} ` : ''}
${seenCount > 1 ? `×${seenCount} ` : ''}
${escapeHtml(relativeTime)}
${msg.text ? `
${escapeHtml(msg.text)}
` : ''}
Flight Details
${msg.flight ? `
Flight
${escapeHtml(msg.flight)}
` : ''}
${msg.tail ? `
Tail #
${escapeHtml(msg.tail)}
` : ''}
${msg.label ? `
Label
${escapeHtml(msg.label)}
` : ''}
${msg.mode ? `
Mode
${escapeHtml(msg.mode)}
` : ''}
${msg.frequency ? `
Frequency
${msg.frequency} MHz
` : ''}
`;
return card;
}
/**
* Build HTML for all meter detail fields from raw message data
*/
function buildMeterDetailsHtml(msg, seenCount) {
let html = '';
const rawMessage = msg.rawMessage || {};
// Add device intelligence info at the top
if (msg.utility && msg.utility !== 'Unknown') {
html += `
Utility Type
${escapeHtml(msg.utility)}
`;
}
if (msg.manufacturer && msg.manufacturer !== 'Unknown') {
html += `
Manufacturer
${escapeHtml(msg.manufacturer)}
`;
}
// Display all fields from the raw rtlamr message
for (const [key, value] of Object.entries(rawMessage)) {
if (value === null || value === undefined) continue;
// Format the label (convert camelCase/PascalCase to spaces)
const label = key.replace(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase()).trim();
// Format the value based on type
let displayValue;
if (Array.isArray(value)) {
// For arrays like DifferentialConsumptionIntervals, show count and values
if (value.length > 10) {
displayValue = `[${value.length} values] ${value.slice(0, 5).join(', ')}...`;
} else {
displayValue = value.join(', ');
}
} else if (typeof value === 'object') {
displayValue = JSON.stringify(value);
} else if (key === 'Consumption') {
displayValue = `${value.toLocaleString()} units`;
} else {
displayValue = String(value);
}
html += `
${escapeHtml(label)}
${escapeHtml(displayValue)}
`;
}
// Add message type if not in raw message
if (!rawMessage.Type && msg.type) {
html += `
Message Type
${escapeHtml(msg.type)}
`;
}
// Add seen count
html += `
Seen
${seenCount} time${seenCount > 1 ? 's' : ''}
`;
return html;
}
/**
* Create a utility meter (rtlamr) card
*/
function createMeterCard(msg, options = {}) {
const status = options.status || determineStatus(msg, 'meter');
const relativeTime = formatRelativeTime(msg.timestamp);
const card = document.createElement('article');
card.className = 'signal-card';
card.dataset.status = status;
card.dataset.type = 'meter';
card.dataset.protocol = msg.type || 'unknown';
if (msg.id) card.dataset.meterId = msg.id;
// Get stats
const stats = getAddressStats('meter', msg.id);
const seenCount = stats ? stats.count : 1;
// Determine meter type color based on utility type
let meterTypeClass = 'electric';
const utility = (msg.utility || '').toLowerCase();
const meterType = (msg.type || '').toLowerCase();
if (utility === 'gas' || meterType.includes('gas')) {
meterTypeClass = 'gas';
} else if (utility === 'water' || meterType.includes('water') || meterType.includes('r900')) {
meterTypeClass = 'water';
}
// Format utility display
const utilityDisplay = msg.utility && msg.utility !== 'Unknown' ? msg.utility : null;
const manufacturerDisplay = msg.manufacturer && msg.manufacturer !== 'Unknown' ? msg.manufacturer : null;
card.innerHTML = `
${manufacturerDisplay ? `${escapeHtml(manufacturerDisplay)} ` : ''}
${msg.type ? `${escapeHtml(msg.type)} ` : ''}
${seenCount > 1 ? `×${seenCount} ` : ''}
${escapeHtml(relativeTime)}
${msg.consumption !== undefined ? `
Consumption
${msg.consumption.toLocaleString()} ${msg.unit || 'units'}
` : ''}
Meter Details
${buildMeterDetailsHtml(msg, seenCount)}
`;
return card;
}
/**
* Create an aggregated utility meter card (grouped by meter ID)
* Shows consumption history, sparkline, delta, and rate
* @param {Object} meter - Aggregated meter data from MeterAggregator
* @param {Object} options - Optional configuration
* @returns {HTMLElement}
*/
function createAggregatedMeterCard(meter, options = {}) {
const status = meter.readingCount === 1 ? 'new' : 'baseline';
const relativeTime = MeterAggregator.getTimeSinceLastReading(meter);
const card = document.createElement('article');
card.className = 'signal-card meter-aggregated';
card.dataset.status = status;
card.dataset.type = 'meter';
card.dataset.protocol = meter.type || 'unknown';
card.dataset.meterId = meter.id;
card.id = 'metercard_' + meter.id;
// Determine meter type color
let meterTypeClass = 'electric';
const utility = (meter.utility || '').toLowerCase();
const meterType = (meter.type || '').toLowerCase();
if (utility === 'gas' || meterType.includes('gas')) {
meterTypeClass = 'gas';
} else if (utility === 'water' || meterType.includes('water') || meterType.includes('r900')) {
meterTypeClass = 'water';
}
// Format utility display
const utilityDisplay = meter.utility && meter.utility !== 'Unknown' ? meter.utility : null;
const manufacturerDisplay = meter.manufacturer && meter.manufacturer !== 'Unknown' ? meter.manufacturer : null;
// Get consumption deltas for sparkline
const deltas = typeof MeterAggregator !== 'undefined'
? MeterAggregator.getConsumptionDeltas(meter)
: [];
// Create sparkline
const sparklineHtml = typeof ConsumptionSparkline !== 'undefined'
? ConsumptionSparkline.createSparklineSvg(deltas, { width: 100, height: 28 })
: '-- ';
// Format delta and rate
const deltaFormatted = MeterAggregator.formatDelta(meter.delta);
const rateFormatted = MeterAggregator.formatRate(meter.rate);
const deltaClass = meter.delta === null ? '' : (meter.delta >= 0 ? 'positive' : 'negative');
// Get latest consumption
const latestConsumption = meter.history.length > 0
? meter.history[meter.history.length - 1].consumption
: null;
card.innerHTML = `
${manufacturerDisplay ? `${escapeHtml(manufacturerDisplay)} ` : ''}
${meter.type ? `${escapeHtml(meter.type)} ` : ''}
${escapeHtml(relativeTime)}
Consumption
${latestConsumption !== null ? latestConsumption.toLocaleString() : '--'}
${deltaFormatted}
Rate
${rateFormatted}
Meter Details
${buildAggregatedMeterDetailsHtml(meter)}
`;
return card;
}
/**
* Update an existing aggregated meter card in place
* @param {HTMLElement} card - The card element to update
* @param {Object} meter - Updated meter data from MeterAggregator
*/
function updateAggregatedMeterCard(card, meter) {
if (!card || !meter) return;
// Update timestamp
const relativeTime = MeterAggregator.getTimeSinceLastReading(meter);
const timestampEl = card.querySelector('.meter-last-seen');
if (timestampEl) {
timestampEl.dataset.timestamp = meter.lastSeen;
timestampEl.textContent = relativeTime;
}
// Update seen count badge
const seenCountEl = card.querySelector('.signal-seen-count');
if (seenCountEl) {
seenCountEl.innerHTML = `×${meter.readingCount}`;
} else if (meter.readingCount > 1) {
// Add seen count if it doesn't exist
const badges = card.querySelector('.signal-card-badges');
if (badges) {
const countSpan = document.createElement('span');
countSpan.className = 'signal-seen-count';
countSpan.innerHTML = `×${meter.readingCount}`;
badges.appendChild(countSpan);
}
}
// Remove "new" status pill after first update
if (meter.readingCount > 1) {
card.dataset.status = 'baseline';
const statusPill = card.querySelector('.signal-status-pill[data-status="new"]');
if (statusPill) {
statusPill.remove();
}
}
// Update consumption value
const latestConsumption = meter.history.length > 0
? meter.history[meter.history.length - 1].consumption
: null;
const consumptionEl = card.querySelector('.consumption-value');
if (consumptionEl) {
consumptionEl.textContent = latestConsumption !== null ? latestConsumption.toLocaleString() : '--';
}
// Update delta
const deltaEl = card.querySelector('.meter-delta');
if (deltaEl) {
const deltaFormatted = MeterAggregator.formatDelta(meter.delta);
deltaEl.textContent = deltaFormatted;
deltaEl.classList.remove('positive', 'negative');
if (meter.delta !== null) {
deltaEl.classList.add(meter.delta >= 0 ? 'positive' : 'negative');
}
}
// Update sparkline
const sparklineContainer = card.querySelector('.meter-sparkline-container');
if (sparklineContainer && typeof ConsumptionSparkline !== 'undefined') {
const deltas = MeterAggregator.getConsumptionDeltas(meter);
sparklineContainer.innerHTML = ConsumptionSparkline.createSparklineSvg(deltas, { width: 100, height: 28 });
}
// Update rate
const rateEl = card.querySelector('.meter-rate-value');
if (rateEl) {
rateEl.textContent = MeterAggregator.formatRate(meter.rate);
}
// Update details panel
const detailsGrid = card.querySelector('.signal-advanced-grid');
if (detailsGrid) {
detailsGrid.innerHTML = buildAggregatedMeterDetailsHtml(meter);
}
// Add subtle update animation
card.classList.add('meter-updated');
setTimeout(() => card.classList.remove('meter-updated'), 300);
}
/**
* Build HTML for aggregated meter detail fields
* @param {Object} meter - Aggregated meter data
* @returns {string} - HTML string
*/
function buildAggregatedMeterDetailsHtml(meter) {
let html = '';
const latestReading = meter.latestReading || {};
const rawMessage = latestReading.Message || {};
// Add device intelligence info at the top
if (meter.utility && meter.utility !== 'Unknown') {
html += `
Utility Type
${escapeHtml(meter.utility)}
`;
}
if (meter.manufacturer && meter.manufacturer !== 'Unknown') {
html += `
Manufacturer
${escapeHtml(meter.manufacturer)}
`;
}
// Add aggregation stats
html += `
Total Readings
${meter.readingCount}
First Seen
${new Date(meter.firstSeen).toLocaleTimeString()}
`;
// Add rate info if available
if (meter.rate !== null) {
html += `
Consumption Rate
${MeterAggregator.formatRate(meter.rate)}
`;
}
// Display fields from the raw rtlamr message
for (const [key, value] of Object.entries(rawMessage)) {
if (value === null || value === undefined) continue;
// Format the label
const label = key.replace(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase()).trim();
// Format the value
let displayValue;
if (Array.isArray(value)) {
if (value.length > 10) {
displayValue = `[${value.length} values] ${value.slice(0, 5).join(', ')}...`;
} else {
displayValue = value.join(', ');
}
} else if (typeof value === 'object') {
displayValue = JSON.stringify(value);
} else if (key === 'Consumption') {
displayValue = `${value.toLocaleString()} units`;
} else {
displayValue = String(value);
}
html += `
${escapeHtml(label)}
${escapeHtml(displayValue)}
`;
}
// Add message type if not in raw message
if (!rawMessage.Type && meter.type) {
html += `
Message Type
${escapeHtml(meter.type)}
`;
}
return html;
}
/**
* Toggle advanced panel on a card
*/
function toggleAdvanced(button) {
const card = button.closest('.signal-card');
const panel = card.querySelector('.signal-advanced-panel');
button.classList.toggle('open');
panel.classList.toggle('open');
}
/**
* Copy message content to clipboard
*/
function copyMessage(button) {
const card = button.closest('.signal-card');
const message = card.querySelector('.signal-message');
if (message) {
navigator.clipboard.writeText(message.textContent).then(() => {
showToast('Content copied');
}).catch(() => {
showToast('Unable to copy content', 'error');
});
}
}
/**
* Mute an address (add to filter list)
*/
function muteAddress(address) {
const muted = JSON.parse(localStorage.getItem('mutedAddresses') || '[]');
if (!muted.includes(address)) {
muted.push(address);
localStorage.setItem('mutedAddresses', JSON.stringify(muted));
showToast(`Source ${address} hidden from view`);
updateMutedIndicator();
// Hide existing cards with this address
document.querySelectorAll(`.signal-card[data-address="${address}"], .signal-card[data-callsign="${address}"], .signal-card[data-sensor-id="${address}"]`).forEach(card => {
card.style.opacity = '0';
card.style.transform = 'scale(0.95)';
setTimeout(() => card.remove(), 200);
});
}
}
/**
* Check if an address is muted
*/
function isAddressMuted(address) {
const muted = JSON.parse(localStorage.getItem('mutedAddresses') || '[]');
return muted.includes(address);
}
/**
* Unmute all addresses and refresh display
*/
function unmuteAll() {
localStorage.setItem('mutedAddresses', '[]');
updateMutedIndicator();
showToast('All sources unmuted');
// Reload to re-display previously muted messages
location.reload();
}
/**
* Update the muted address count indicator in the sidebar
*/
function updateMutedIndicator() {
const muted = JSON.parse(localStorage.getItem('mutedAddresses') || '[]');
const info = document.getElementById('mutedAddressInfo');
const count = document.getElementById('mutedAddressCount');
if (info && count) {
count.textContent = muted.length;
info.style.display = muted.length > 0 ? 'block' : 'none';
}
}
/**
* Show location on map (for APRS)
*/
function showOnMap(lat, lon, label) {
// Trigger custom event that map components can listen to
const event = new CustomEvent('showOnMap', {
detail: { lat, lon, label }
});
document.dispatchEvent(event);
showToast(`Displaying ${label} location`);
}
/**
* Show raw data modal for a station
*/
function showStationRawData(element) {
const callsign = element.dataset.callsign || 'Unknown';
const rawData = element.dataset.raw || '';
// Create or reuse modal
let modal = document.getElementById('stationRawDataModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'stationRawDataModal';
modal.className = 'station-raw-modal';
modal.innerHTML = `
`;
document.body.appendChild(modal);
// Close handlers
modal.querySelector('.station-raw-modal-backdrop').addEventListener('click', () => {
modal.classList.remove('show');
});
modal.querySelector('.station-raw-modal-close').addEventListener('click', () => {
modal.classList.remove('show');
});
modal.querySelector('.station-raw-copy-btn').addEventListener('click', () => {
const rawText = modal.querySelector('.station-raw-data-display').textContent;
navigator.clipboard.writeText(rawText).then(() => {
showToast('Raw data copied to clipboard');
}).catch(() => {
showToast('Failed to copy', 'error');
});
});
}
// Populate modal
modal.querySelector('.station-raw-modal-title').textContent = `Station: ${callsign}`;
modal.querySelector('.station-raw-data-display').textContent = rawData || 'No raw data available';
// Show modal
modal.classList.add('show');
}
/**
* Show signal details dialog for pager/sensor cards
*/
function showSignalDetails(card) {
const type = card.dataset.type;
const msgData = JSON.parse(card.dataset.msgData || '{}');
// Create or reuse modal
let modal = document.getElementById('signalDetailsModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'signalDetailsModal';
modal.className = 'signal-details-modal';
modal.innerHTML = `
`;
document.body.appendChild(modal);
// Close handlers
modal.querySelector('.signal-details-modal-backdrop').addEventListener('click', () => {
modal.classList.remove('show');
});
modal.querySelector('.signal-details-modal-close').addEventListener('click', () => {
modal.classList.remove('show');
});
modal.querySelector('.signal-details-copy-btn').addEventListener('click', () => {
const rawEl = modal.querySelector('.signal-raw-data');
if (rawEl) {
navigator.clipboard.writeText(rawEl.textContent).then(() => {
showToast('Raw data copied to clipboard');
}).catch(() => {
showToast('Failed to copy', 'error');
});
}
});
// Close on escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal.classList.contains('show')) {
modal.classList.remove('show');
}
});
}
// Build content based on card type
let title = '';
let bodyContent = '';
if (type === 'message') {
// Pager message details
title = `${escapeHtml(msgData.protocol || 'Pager')} - Address ${escapeHtml(msgData.address || 'Unknown')}`;
const stats = getAddressStats('pager', msgData.address);
const seenCount = stats ? stats.count : 1;
const msgType = getMsgTypeLabel(msgData);
bodyContent = `
Message Content
${escapeHtml(msgData.message || '[No content]')}
Signal Details
Protocol
${escapeHtml(msgData.protocol || 'Unknown')}
Address
${escapeHtml(msgData.address || 'Unknown')}
${msgData.function ? `
Function
${escapeHtml(msgData.function)}
` : ''}
Type
${escapeHtml(msgType)}
Seen
${seenCount} time${seenCount > 1 ? 's' : ''}
Timestamp
${escapeHtml(msgData.timestamp || 'Unknown')}
${msgData.raw ? `
Raw Data
${escapeHtml(msgData.raw)}
` : ''}
`;
} else if (type === 'sensor') {
// 433MHz sensor details
title = `${escapeHtml(msgData.model || 'Sensor')} - ID ${escapeHtml(msgData.id || 'Unknown')}`;
const stats = getAddressStats('sensor', msgData.id);
const seenCount = stats ? stats.count : 1;
const rssi = msgData.rssi || msgData.signal_strength || msgData.snr || msgData.noise || null;
// Signal assessment section
let signalAssessment = '';
if (rssi !== null) {
signalAssessment = createSignalAssessmentPanel(rssi, stats?.lastSeen ? (Date.now() - stats.firstSeen) / 1000 : null, seenCount);
}
// Signal guess section
let signalGuessHtml = '';
if (msgData.frequency && typeof SignalGuess !== 'undefined') {
const frequencyHz = parseFloat(msgData.frequency) * 1_000_000;
const signalGuess = SignalGuess.guessSignalType({
frequency_hz: frequencyHz,
modulation: msgData.modulation || null,
bandwidth_hz: msgData.bandwidth ? parseFloat(msgData.bandwidth) * 1000 : null,
rssi_dbm: rssi,
region: 'UK/EU'
});
if (signalGuess) {
const guessElement = SignalGuess.createGuessElement(signalGuess, { showAlternatives: true, compact: false });
signalGuessHtml = `
Signal Identification
${guessElement.outerHTML}
`;
}
}
// Sensor readings
let sensorReadings = '';
const readings = [];
if (msgData.temperature !== undefined) readings.push(`Temperature ${msgData.temperature}°${msgData.temperature_unit || 'F'}
`);
if (msgData.humidity !== undefined) readings.push(`Humidity ${msgData.humidity}%
`);
if (msgData.battery !== undefined) readings.push(`Battery ${msgData.battery}
`);
if (msgData.pressure !== undefined) readings.push(`Pressure ${msgData.pressure} ${msgData.pressure_unit || 'hPa'}
`);
if (msgData.wind_speed !== undefined) readings.push(`Wind Speed ${msgData.wind_speed} ${msgData.wind_unit || 'mph'}
`);
if (msgData.rain !== undefined) readings.push(`Rain ${msgData.rain} ${msgData.rain_unit || 'mm'}
`);
if (msgData.state !== undefined) readings.push(`State ${escapeHtml(msgData.state)}
`);
if (readings.length > 0) {
sensorReadings = `
Sensor Readings
${readings.join('')}
`;
}
bodyContent = `
${signalAssessment}
${signalGuessHtml}
${sensorReadings}
Sensor Details
Model
${escapeHtml(msgData.model || 'Unknown')}
ID
${escapeHtml(msgData.id || 'N/A')}
${msgData.channel ? `
Channel
${msgData.channel}
` : ''}
${msgData.frequency ? `
Frequency
${msgData.frequency} MHz
` : ''}
Seen
${seenCount} time${seenCount > 1 ? 's' : ''}
Timestamp
${escapeHtml(msgData.timestamp || 'Unknown')}
${msgData.raw ? `
Raw Data
${escapeHtml(typeof msgData.raw === 'object' ? JSON.stringify(msgData.raw, null, 2) : msgData.raw)}
` : ''}
`;
}
// Populate modal
modal.querySelector('.signal-details-modal-title').textContent = title;
modal.querySelector('.signal-details-modal-body').innerHTML = bodyContent;
// Show/hide copy button based on whether there's raw data
const copyBtn = modal.querySelector('.signal-details-copy-btn');
copyBtn.style.display = (msgData.raw) ? '' : 'none';
// Show modal
modal.classList.add('show');
}
/**
* Show toast notification
*/
function showToast(message, type = 'success') {
let toast = document.getElementById('signalToast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'signalToast';
toast.className = 'signal-toast';
document.body.appendChild(toast);
}
toast.textContent = message;
toast.className = 'signal-toast ' + type;
toast.offsetHeight; // Force reflow
toast.classList.add('show');
setTimeout(() => {
toast.classList.remove('show');
}, 2500);
}
/**
* Create pager filter bar with protocol and message type filters
*/
function createPagerFilterBar(outputContainer, options = {}) {
const filterBar = document.createElement('div');
filterBar.className = 'signal-filter-bar';
filterBar.id = 'pagerFilterBar';
filterBar.innerHTML = `
Status
All
0
New
0
Repeated
0
Burst
0
Protocol
All
POCSAG
FLEX
Type
All
Alpha
Numeric
Tone
`;
// Add click handlers for filter buttons
filterBar.querySelectorAll('.signal-filter-btn[data-filter="status"]').forEach(btn => {
btn.addEventListener('click', () => {
filterBar.querySelectorAll('.signal-filter-btn[data-filter="status"]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.filters.status = btn.dataset.value;
applyAllFilters(outputContainer);
});
});
filterBar.querySelectorAll('.signal-filter-btn[data-filter="protocol"]').forEach(btn => {
btn.addEventListener('click', () => {
filterBar.querySelectorAll('.signal-filter-btn[data-filter="protocol"]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.filters.protocol = btn.dataset.value;
applyAllFilters(outputContainer);
});
});
filterBar.querySelectorAll('.signal-filter-btn[data-filter="msgType"]').forEach(btn => {
btn.addEventListener('click', () => {
filterBar.querySelectorAll('.signal-filter-btn[data-filter="msgType"]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.filters.msgType = btn.dataset.value;
applyAllFilters(outputContainer);
});
});
// Add search handler with debounce
const searchInput = filterBar.querySelector('#pagerSearchInput');
let searchTimeout;
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
state.filters.search = e.target.value.toLowerCase();
applyAllFilters(outputContainer);
}, 200);
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Only when not typing in an input
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === '/') {
e.preventDefault();
searchInput.focus();
}
});
return filterBar;
}
/**
* Apply all filters (status, protocol, msgType, search)
*/
function applyAllFilters(container) {
const cards = container.querySelectorAll('.signal-card');
let visibleCount = 0;
const counts = {
all: 0,
new: 0,
repeated: 0,
burst: 0,
baseline: 0,
emergency: 0
};
cards.forEach(card => {
const cardStatus = card.dataset.status;
const cardProtocol = card.dataset.protocol;
const cardMsgType = card.dataset.msgType;
const cardAddress = card.dataset.address || '';
const cardContent = card.querySelector('.signal-message')?.textContent || '';
// Count all cards by status
counts.all++;
if (counts.hasOwnProperty(cardStatus)) {
counts[cardStatus]++;
}
// Check all filters
const statusMatch = state.filters.status === 'all' || cardStatus === state.filters.status;
const protocolMatch = state.filters.protocol === 'all' || cardProtocol === state.filters.protocol;
const typeMatch = state.filters.msgType === 'all' || cardMsgType === state.filters.msgType;
const searchMatch = !state.filters.search ||
cardAddress.toLowerCase().includes(state.filters.search) ||
cardContent.toLowerCase().includes(state.filters.search);
if (statusMatch && protocolMatch && typeMatch && searchMatch) {
card.classList.remove('hidden');
visibleCount++;
} else {
card.classList.add('hidden');
}
});
// Update count badges - find filter bar in multiple possible locations
const filterBars = [
document.getElementById('filterBarContainer')?.querySelector('.signal-filter-bar'),
document.getElementById('aprsFilterBarContainer')?.querySelector('.signal-filter-bar')
].filter(Boolean);
filterBars.forEach(filterBar => {
Object.keys(counts).forEach(key => {
const badge = filterBar.querySelector(`[data-count="${key}"]`);
if (badge) {
badge.textContent = counts[key];
}
});
});
// Show/hide empty state
const emptyState = container.querySelector('.signal-empty-state');
if (emptyState) {
emptyState.style.display = visibleCount === 0 && cards.length > 0 ? 'block' : 'none';
}
state.counts = counts;
}
/**
* Initialize filter bar (legacy support)
*/
function initFilterBar(container, options = {}) {
return createPagerFilterBar(container, options);
}
/**
* Apply current filters to cards (legacy support)
*/
function applyFilters(container) {
applyAllFilters(container);
}
/**
* Update filter counts
*/
function updateCounts(container) {
applyAllFilters(container);
return state.counts;
}
/**
* Create APRS filter bar with status and packet type filters
*/
function createAprsFilterBar(outputContainer, options = {}) {
const filterBar = document.createElement('div');
filterBar.className = 'signal-filter-bar signal-filter-bar-compact';
filterBar.id = 'aprsFilterBar';
filterBar.innerHTML = `
All
0
New
0
Repeated
0
Type
All
Position
Weather
Message
`;
// Store filter state specific to APRS
const aprsFilters = { status: 'all', packetType: 'all', search: '' };
// Apply filters function for APRS
const applyAprsFilters = () => {
const cards = outputContainer.querySelectorAll('.signal-card');
let visibleCount = 0;
const counts = { all: 0, new: 0, repeated: 0, burst: 0, baseline: 0, emergency: 0 };
cards.forEach(card => {
const cardStatus = card.dataset.status;
const cardType = card.dataset.packetType || card.querySelector('.signal-msg-type')?.textContent?.toLowerCase() || '';
const cardCallsign = card.dataset.callsign || '';
counts.all++;
if (counts.hasOwnProperty(cardStatus)) counts[cardStatus]++;
const statusMatch = aprsFilters.status === 'all' || cardStatus === aprsFilters.status;
const typeMatch = aprsFilters.packetType === 'all' || cardType.includes(aprsFilters.packetType);
const searchMatch = !aprsFilters.search || cardCallsign.toLowerCase().includes(aprsFilters.search);
if (statusMatch && typeMatch && searchMatch) {
card.classList.remove('hidden');
visibleCount++;
} else {
card.classList.add('hidden');
}
});
// Update count badges
Object.keys(counts).forEach(key => {
const badge = filterBar.querySelector(`[data-count="${key}"]`);
if (badge) badge.textContent = counts[key];
});
};
// Status filter handlers
filterBar.querySelectorAll('.signal-filter-btn[data-filter="status"]').forEach(btn => {
btn.addEventListener('click', () => {
filterBar.querySelectorAll('.signal-filter-btn[data-filter="status"]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
aprsFilters.status = btn.dataset.value;
applyAprsFilters();
});
});
// Packet type filter handlers
filterBar.querySelectorAll('.signal-filter-btn[data-filter="packetType"]').forEach(btn => {
btn.addEventListener('click', () => {
filterBar.querySelectorAll('.signal-filter-btn[data-filter="packetType"]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
aprsFilters.packetType = btn.dataset.value;
applyAprsFilters();
});
});
// Search handler with debounce
const searchInput = filterBar.querySelector('#aprsSearchInput');
let searchTimeout;
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
aprsFilters.search = e.target.value.toLowerCase();
applyAprsFilters();
}, 200);
});
// Store applyFilters reference for external calls
filterBar.applyFilters = applyAprsFilters;
return filterBar;
}
/**
* Create Sensor (433MHz) filter bar
*/
function createSensorFilterBar(outputContainer, options = {}) {
const filterBar = document.createElement('div');
filterBar.className = 'signal-filter-bar';
filterBar.id = 'sensorFilterBar';
filterBar.innerHTML = `
Status
All
0
New
0
Repeated
0
Burst
0
`;
// Store filter state for sensors
const sensorFilters = { status: 'all', search: '' };
// Apply filters function for sensors
const applySensorFilters = () => {
const cards = outputContainer.querySelectorAll('.signal-card');
let visibleCount = 0;
const counts = { all: 0, new: 0, repeated: 0, burst: 0, baseline: 0, emergency: 0 };
cards.forEach(card => {
const cardStatus = card.dataset.status;
const cardProtocol = card.dataset.protocol || '';
const cardSensorId = card.dataset.sensorId || '';
counts.all++;
if (counts.hasOwnProperty(cardStatus)) counts[cardStatus]++;
const statusMatch = sensorFilters.status === 'all' || cardStatus === sensorFilters.status;
const searchMatch = !sensorFilters.search ||
cardProtocol.toLowerCase().includes(sensorFilters.search) ||
cardSensorId.toLowerCase().includes(sensorFilters.search);
if (statusMatch && searchMatch) {
card.classList.remove('hidden');
visibleCount++;
} else {
card.classList.add('hidden');
}
});
// Update count badges
Object.keys(counts).forEach(key => {
const badge = filterBar.querySelector(`[data-count="${key}"]`);
if (badge) badge.textContent = counts[key];
});
};
// Status filter handlers
filterBar.querySelectorAll('.signal-filter-btn[data-filter="status"]').forEach(btn => {
btn.addEventListener('click', () => {
filterBar.querySelectorAll('.signal-filter-btn[data-filter="status"]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
sensorFilters.status = btn.dataset.value;
applySensorFilters();
});
});
// Search handler with debounce
const searchInput = filterBar.querySelector('#sensorSearchInput');
let searchTimeout;
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
sensorFilters.search = e.target.value.toLowerCase();
applySensorFilters();
}, 200);
});
// Store applyFilters reference for external calls
filterBar.applyFilters = applySensorFilters;
return filterBar;
}
/**
* Update relative timestamps on cards
*/
function updateTimestamps(container) {
container.querySelectorAll('.signal-timestamp[data-timestamp]').forEach(el => {
const timestamp = el.dataset.timestamp;
if (timestamp) {
el.textContent = formatRelativeTime(timestamp);
}
});
}
// Public API
return {
// Card creators
createPagerCard,
createAprsCard,
createSensorCard,
createAcarsCard,
createMeterCard,
createAggregatedMeterCard,
updateAggregatedMeterCard,
// Signal classification
SignalClassification,
createSignalIndicator,
createSignalAssessmentPanel,
// UI interactions
toggleAdvanced,
copyMessage,
muteAddress,
isAddressMuted,
unmuteAll,
updateMutedIndicator,
showOnMap,
showStationRawData,
showSignalDetails,
showToast,
// Filter bar
createPagerFilterBar,
createAprsFilterBar,
createSensorFilterBar,
initFilterBar,
applyFilters,
applyAllFilters,
updateCounts,
updateTimestamps,
// Address tracking
trackAddress,
getAddressStats,
clearAddressHistory,
// Utilities
escapeHtml,
formatRelativeTime,
determineStatus,
getProtoClass,
// State
state,
addressHistory
};
})();
// Make globally available
window.SignalCards = SignalCards;