/** * 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(msg.protocol)} Addr: ${escapeHtml(msg.address)}${msg.function ? ' / F' + escapeHtml(msg.function) : ''}
${status !== 'baseline' ? ` ${status.charAt(0).toUpperCase() + status.slice(1)} ` : ''}
${escapeHtml(msgType)} ${seenCount > 1 ? `×${seenCount}` : ''} ${escapeHtml(relativeTime)}
${escapeHtml(msg.message || '[No content]')}
${!isToneOnly ? `` : ''}
`; // 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 = `
APRS ${escapeHtml(msg.callsign || 'Unknown')}
${status !== 'baseline' ? ` ${status.charAt(0).toUpperCase() + status.slice(1)} ` : ''}
${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 = `
${escapeHtml(msg.model || 'Unknown')} ID: ${escapeHtml(msg.id || 'N/A')} ${signalIndicator} ${signalGuessBadge}
${status !== 'baseline' ? ` ${status.charAt(0).toUpperCase() + status.slice(1)} ` : ''}
${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)}
` : ''}
`; // 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 = `
ACARS ${escapeHtml(msg.flight || msg.tail || 'Unknown')}
${status !== 'baseline' ? ` ${status.charAt(0).toUpperCase() + status.slice(1)} ` : ''}
${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 = `
${escapeHtml(utilityDisplay || msg.type || 'Meter')} ID: ${escapeHtml(msg.id || 'N/A')}
${status !== 'baseline' ? ` ${status.charAt(0).toUpperCase() + status.slice(1)} ` : ''}
${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 = `
${escapeHtml(utilityDisplay || meter.type || 'Meter')} ID: ${escapeHtml(meter.id || 'N/A')} ${meter.readingCount > 1 ? `×${meter.readingCount}` : ''}
${status === 'new' ? ` New ` : ''}
${manufacturerDisplay ? `${escapeHtml(manufacturerDisplay)}` : ''} ${meter.type ? `${escapeHtml(meter.type)}` : ''} ${escapeHtml(relativeTime)}
Consumption ${latestConsumption !== null ? latestConsumption.toLocaleString() : '--'} ${deltaFormatted}
Trend
${sparklineHtml}
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 = `
Raw Packet Data

                    
`; 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 Protocol Type
`; // 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 = ` Type
`; // 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
`; // 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;