Files
intercept/static/js/components/signal-cards.js
Smittix 2cb62d5f34 Standardize all icons to uniform inline SVG format
Replace emojis throughout the codebase with inline SVG icons using
the Icons utility. Remove decorative icons where text labels already
describe the content. Add classification dot CSS for risk indicators.

- Extend Icons utility with comprehensive SVG icon set
- Update navigation, header stats, and action buttons
- Update playback controls and volume icons
- Remove decorative device type and panel header emojis
- Clean up notifications and alert messages
- Add CSS for classification status dots

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:29:28 +00:00

1721 lines
74 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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: 'At detection threshold',
interpretation: 'may be ambient noise or distant source',
confidence: 'low',
color: '#888888',
icon: 'signal-0',
bars: 1
},
weak: {
label: 'Weak',
description: 'Detectable signal',
interpretation: 'potentially distant or obstructed',
confidence: 'low',
color: '#6baed6',
icon: 'signal-1',
bars: 2
},
moderate: {
label: 'Moderate',
description: 'Consistent presence',
interpretation: 'likely in proximity',
confidence: 'medium',
color: '#3182bd',
icon: 'signal-2',
bars: 3
},
strong: {
label: 'Strong',
description: 'Clear signal',
interpretation: 'probable close proximity',
confidence: 'medium',
color: '#fd8d3c',
icon: 'signal-3',
bars: 4
},
very_strong: {
label: 'Very Strong',
description: 'High signal level',
interpretation: 'indicates likely 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: 'briefly observed',
confidence_impact: 'reduces confidence'
},
short: {
label: 'Short-duration',
modifier: 'observed for a short period',
confidence_impact: 'limited confidence'
},
sustained: {
label: 'Sustained',
modifier: 'observed over sustained period',
confidence_impact: 'supports confidence'
},
persistent: {
label: 'Persistent',
modifier: 'continuously observed',
confidence_impact: 'increases 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 device presence in proximity`;
} else if (confidence === 'medium') {
return `${strengthInfo.label}, ${durationInfo.label.toLowerCase()} signal that may indicate device activity`;
} else {
return `${durationInfo.modifier.charAt(0).toUpperCase() + durationInfo.modifier.slice(1)} ${strengthInfo.label.toLowerCase()} signal consistent with possible device presence`;
}
},
/**
* 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 `Observed signal characteristics suggest ${base}`;
} else if (confidence === 'medium') {
return `Signal pattern may indicate ${base}`;
} else {
return `Limited data; signal could 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 affected by walls, interference, and transmit 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 '';
const date = new Date(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 = `
<svg class="signal-strength-bars" viewBox="0 0 20 16" width="${compact ? 16 : 20}" height="${compact ? 12 : 16}">
<rect x="0" y="12" width="3" height="4" fill="${bars >= 1 ? info.color : '#444'}"/>
<rect x="4" y="9" width="3" height="7" fill="${bars >= 2 ? info.color : '#444'}"/>
<rect x="8" y="6" width="3" height="10" fill="${bars >= 3 ? info.color : '#444'}"/>
<rect x="12" y="3" width="3" height="13" fill="${bars >= 4 ? info.color : '#444'}"/>
<rect x="16" y="0" width="3" height="16" fill="${bars >= 5 ? info.color : '#444'}"/>
</svg>
`;
// 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 `
<span class="signal-strength-indicator compact ${confidenceClass}"
${showTooltip ? `title="${escapeHtml(tooltipContent)}"` : ''}>
${barsSvg}
</span>
`;
}
return `
<span class="signal-strength-indicator ${confidenceClass}"
${showTooltip ? `title="${escapeHtml(tooltipContent)}"` : ''}>
${barsSvg}
${showLabel ? `<span class="signal-strength-label" style="color: ${info.color}">${info.label}</span>` : ''}
</span>
`;
}
/**
* 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 `
<div class="signal-advanced-section signal-assessment">
<div class="signal-advanced-title">Signal Assessment</div>
<div class="signal-assessment-summary">
${createSignalIndicator(rssi, { compact: false, showTooltip: false })}
<span class="signal-assessment-text">${escapeHtml(interpretation)}</span>
</div>
<div class="signal-advanced-grid">
<div class="signal-advanced-item">
<span class="signal-advanced-label">Signal Strength</span>
<span class="signal-advanced-value">${strengthInfo.label} (${rssi} dBm)</span>
</div>
<div class="signal-advanced-item">
<span class="signal-advanced-label">Detection</span>
<span class="signal-advanced-value">${durationInfo.label}</span>
</div>
<div class="signal-advanced-item">
<span class="signal-advanced-label">Est. Range</span>
<span class="signal-advanced-value">${rangeEst.estimate}</span>
</div>
<div class="signal-advanced-item">
<span class="signal-advanced-label">Confidence</span>
<span class="signal-advanced-value signal-confidence-${confidence}">${confidence.charAt(0).toUpperCase() + confidence.slice(1)}</span>
</div>
</div>
<div class="signal-assessment-caveat">
Note: ${rangeEst.disclaimer}
</div>
</div>
`;
}
/**
* 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';
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;
// Get address stats for display
const stats = getAddressStats('pager', msg.address);
const seenCount = stats ? stats.count : 1;
card.innerHTML = `
<div class="signal-card-header">
<div class="signal-card-badges">
<span class="signal-proto-badge ${protoClass}">${escapeHtml(msg.protocol)}</span>
<span class="signal-freq-badge">Addr: ${escapeHtml(msg.address)}${msg.function ? ' / F' + escapeHtml(msg.function) : ''}</span>
</div>
${status !== 'baseline' ? `
<span class="signal-status-pill" data-status="${status}">
<span class="status-dot"></span>
${status.charAt(0).toUpperCase() + status.slice(1)}
</span>
` : ''}
</div>
<div class="signal-card-body">
<div class="signal-meta-row">
<span class="signal-msg-type">${escapeHtml(msgType)}</span>
${seenCount > 1 ? `<span class="signal-seen-count" title="Messages from this address">×${seenCount}</span>` : ''}
<span class="signal-timestamp" data-timestamp="${escapeHtml(msg.timestamp)}" title="${escapeHtml(msg.timestamp)}">${escapeHtml(relativeTime)}</span>
</div>
<div class="signal-message ${isNumeric ? 'numeric' : ''} ${isToneOnly ? 'tone-only' : ''}">${escapeHtml(msg.message || '[No content]')}</div>
</div>
<div class="signal-card-footer">
<button class="signal-advanced-toggle" onclick="SignalCards.toggleAdvanced(this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
Details
</button>
<div class="signal-card-actions">
${!isToneOnly ? `<button class="signal-action-btn" onclick="SignalCards.copyMessage(this)">Copy</button>` : ''}
<button class="signal-action-btn" onclick="SignalCards.muteAddress('${escapeHtml(msg.address)}')">Mute</button>
</div>
</div>
<div class="signal-advanced-panel">
<div class="signal-advanced-inner">
<div class="signal-advanced-content">
<div class="signal-advanced-section">
<div class="signal-advanced-title">Signal Details</div>
<div class="signal-advanced-grid">
<div class="signal-advanced-item">
<span class="signal-advanced-label">Protocol</span>
<span class="signal-advanced-value">${escapeHtml(msg.protocol)}</span>
</div>
<div class="signal-advanced-item">
<span class="signal-advanced-label">Address</span>
<span class="signal-advanced-value">${escapeHtml(msg.address)}</span>
</div>
${msg.function ? `
<div class="signal-advanced-item">
<span class="signal-advanced-label">Function</span>
<span class="signal-advanced-value">${escapeHtml(msg.function)}</span>
</div>
` : ''}
<div class="signal-advanced-item">
<span class="signal-advanced-label">Type</span>
<span class="signal-advanced-value">${escapeHtml(msgType)}</span>
</div>
<div class="signal-advanced-item">
<span class="signal-advanced-label">Seen</span>
<span class="signal-advanced-value">${seenCount} time${seenCount > 1 ? 's' : ''}</span>
</div>
<div class="signal-advanced-item">
<span class="signal-advanced-label">Timestamp</span>
<span class="signal-advanced-value">${escapeHtml(msg.timestamp)}</span>
</div>
</div>
</div>
${msg.raw ? `
<div class="signal-advanced-section">
<div class="signal-advanced-title">Raw Data</div>
<div class="signal-raw-data">${escapeHtml(msg.raw)}</div>
</div>
` : ''}
</div>
</div>
</div>
`;
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 = `
<div class="signal-card-header">
<div class="signal-card-badges">
<span class="signal-proto-badge aprs">APRS</span>
<span class="signal-freq-badge">${escapeHtml(msg.callsign || 'Unknown')}</span>
</div>
${status !== 'baseline' ? `
<span class="signal-status-pill" data-status="${status}">
<span class="status-dot"></span>
${status.charAt(0).toUpperCase() + status.slice(1)}
</span>
` : ''}
</div>
<div class="signal-card-body">
<div class="signal-meta-row">
<span class="signal-msg-type">${aprsType.charAt(0).toUpperCase() + aprsType.slice(1)}</span>
${msg.symbol ? `<span class="signal-aprs-symbol" title="APRS Symbol">${escapeHtml(msg.symbol)}</span>` : ''}
${msg.distance !== null && msg.distance !== undefined ? `<span class="signal-distance">${msg.distance.toFixed(1)} mi</span>` : ''}
${seenCount > 1 ? `<span class="signal-seen-count">×${seenCount}</span>` : ''}
<span class="signal-timestamp" data-timestamp="${escapeHtml(msg.timestamp)}">${escapeHtml(relativeTime)}</span>
</div>
${msg.comment || msg.status || msg.message ? `
<div class="signal-message">${escapeHtml(msg.comment || msg.status || msg.message)}</div>
` : ''}
${msg.weather ? `
<div class="signal-message">
${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}° ` : ''}
</div>
` : ''}
${hasPosition ? `
<div class="signal-mini-map" onclick="SignalCards.showOnMap(${msg.latitude}, ${msg.longitude}, '${escapeHtml(msg.callsign)}')">
<div class="signal-map-pin"></div>
<span class="signal-map-coords">${msg.latitude.toFixed(4)}°, ${msg.longitude.toFixed(4)}°</span>
</div>
` : ''}
</div>
<div class="signal-card-footer">
<button class="signal-advanced-toggle" onclick="SignalCards.toggleAdvanced(this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
Details
</button>
<div class="signal-card-actions">
${hasPosition ? `<button class="signal-action-btn primary" onclick="SignalCards.showOnMap(${msg.latitude}, ${msg.longitude}, '${escapeHtml(msg.callsign)}')">Map</button>` : ''}
<button class="signal-action-btn" onclick="SignalCards.muteAddress('${escapeHtml(msg.callsign)}')">Mute</button>
</div>
</div>
<div class="signal-advanced-panel">
<div class="signal-advanced-inner">
<div class="signal-advanced-content">
<div class="signal-advanced-section">
<div class="signal-advanced-title">Station Details</div>
<div class="signal-advanced-grid">
<div class="signal-advanced-item">
<span class="signal-advanced-label">Callsign</span>
<span class="signal-advanced-value">${escapeHtml(msg.callsign)}</span>
</div>
${msg.path ? `
<div class="signal-advanced-item">
<span class="signal-advanced-label">Path</span>
<span class="signal-advanced-value">${escapeHtml(msg.path)}</span>
</div>
` : ''}
${hasPosition ? `
<div class="signal-advanced-item">
<span class="signal-advanced-label">Position</span>
<span class="signal-advanced-value">${msg.latitude.toFixed(5)}°, ${msg.longitude.toFixed(5)}°</span>
</div>
` : ''}
${msg.altitude ? `
<div class="signal-advanced-item">
<span class="signal-advanced-label">Altitude</span>
<span class="signal-advanced-value">${msg.altitude} ft</span>
</div>
` : ''}
${msg.speed ? `
<div class="signal-advanced-item">
<span class="signal-advanced-label">Speed</span>
<span class="signal-advanced-value">${msg.speed} mph</span>
</div>
` : ''}
${msg.course ? `
<div class="signal-advanced-item">
<span class="signal-advanced-label">Course</span>
<span class="signal-advanced-value">${msg.course}°</span>
</div>
` : ''}
<div class="signal-advanced-item">
<span class="signal-advanced-label">Seen</span>
<span class="signal-advanced-value">${seenCount} time${seenCount > 1 ? 's' : ''}</span>
</div>
</div>
</div>
${msg.raw ? `
<div class="signal-advanced-section">
<div class="signal-advanced-title">Raw Packet</div>
<div class="signal-raw-data">${escapeHtml(msg.raw)}</div>
</div>
` : ''}
</div>
</div>
</div>
`;
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';
card.dataset.status = status;
card.dataset.type = 'sensor';
card.dataset.protocol = msg.model || 'unknown';
if (msg.id) card.dataset.sensorId = msg.id;
// 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 })
: '<span class="signal-strength-indicator compact no-data" title="No signal data available">--</span>';
card.innerHTML = `
<div class="signal-card-header">
<div class="signal-card-badges">
<span class="signal-proto-badge sensor">${escapeHtml(msg.model || 'Unknown')}</span>
<span class="signal-freq-badge">ID: ${escapeHtml(msg.id || 'N/A')}</span>
${signalIndicator}
</div>
${status !== 'baseline' ? `
<span class="signal-status-pill" data-status="${status}">
<span class="status-dot"></span>
${status.charAt(0).toUpperCase() + status.slice(1)}
</span>
` : ''}
</div>
<div class="signal-card-body">
<div class="signal-meta-row">
${msg.channel ? `<span class="signal-msg-type">Ch ${msg.channel}</span>` : ''}
${seenCount > 1 ? `<span class="signal-seen-count">×${seenCount}</span>` : ''}
<span class="signal-timestamp" data-timestamp="${escapeHtml(msg.timestamp)}">${escapeHtml(relativeTime)}</span>
</div>
<div class="signal-sensor-data">
${msg.temperature !== undefined ? `
<div class="signal-sensor-reading">
<span class="sensor-label">Temp</span>
<span class="sensor-value">${msg.temperature}°${msg.temperature_unit || 'F'}</span>
</div>
` : ''}
${msg.humidity !== undefined ? `
<div class="signal-sensor-reading">
<span class="sensor-label">Humidity</span>
<span class="sensor-value">${msg.humidity}%</span>
</div>
` : ''}
${msg.battery !== undefined ? `
<div class="signal-sensor-reading">
<span class="sensor-label">Battery</span>
<span class="sensor-value ${msg.battery === 'LOW' ? 'low-battery' : ''}">${msg.battery}</span>
</div>
` : ''}
${msg.pressure !== undefined ? `
<div class="signal-sensor-reading">
<span class="sensor-label">Pressure</span>
<span class="sensor-value">${msg.pressure} ${msg.pressure_unit || 'hPa'}</span>
</div>
` : ''}
${msg.wind_speed !== undefined ? `
<div class="signal-sensor-reading">
<span class="sensor-label">Wind</span>
<span class="sensor-value">${msg.wind_speed} ${msg.wind_unit || 'mph'}</span>
</div>
` : ''}
${msg.rain !== undefined ? `
<div class="signal-sensor-reading">
<span class="sensor-label">Rain</span>
<span class="sensor-value">${msg.rain} ${msg.rain_unit || 'mm'}</span>
</div>
` : ''}
${msg.state !== undefined ? `
<div class="signal-sensor-reading">
<span class="sensor-label">State</span>
<span class="sensor-value">${escapeHtml(msg.state)}</span>
</div>
` : ''}
</div>
</div>
<div class="signal-card-footer">
<button class="signal-advanced-toggle" onclick="SignalCards.toggleAdvanced(this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
Details
</button>
<div class="signal-card-actions">
<button class="signal-action-btn" onclick="SignalCards.muteAddress('${escapeHtml(msg.id)}')">Mute</button>
</div>
</div>
<div class="signal-advanced-panel">
<div class="signal-advanced-inner">
<div class="signal-advanced-content">
${rssi !== null ? createSignalAssessmentPanel(rssi, stats?.lastSeen ? (Date.now() - stats.firstSeen) / 1000 : null, seenCount) : ''}
<div class="signal-advanced-section">
<div class="signal-advanced-title">Sensor Details</div>
<div class="signal-advanced-grid">
<div class="signal-advanced-item">
<span class="signal-advanced-label">Model</span>
<span class="signal-advanced-value">${escapeHtml(msg.model || 'Unknown')}</span>
</div>
<div class="signal-advanced-item">
<span class="signal-advanced-label">ID</span>
<span class="signal-advanced-value">${escapeHtml(msg.id || 'N/A')}</span>
</div>
${msg.channel ? `
<div class="signal-advanced-item">
<span class="signal-advanced-label">Channel</span>
<span class="signal-advanced-value">${msg.channel}</span>
</div>
` : ''}
${msg.frequency ? `
<div class="signal-advanced-item">
<span class="signal-advanced-label">Frequency</span>
<span class="signal-advanced-value">${msg.frequency} MHz</span>
</div>
` : ''}
<div class="signal-advanced-item">
<span class="signal-advanced-label">Seen</span>
<span class="signal-advanced-value">${seenCount} time${seenCount > 1 ? 's' : ''}</span>
</div>
</div>
</div>
${msg.raw ? `
<div class="signal-advanced-section">
<div class="signal-advanced-title">Raw Data</div>
<div class="signal-raw-data">${escapeHtml(typeof msg.raw === 'object' ? JSON.stringify(msg.raw, null, 2) : msg.raw)}</div>
</div>
` : ''}
</div>
</div>
</div>
`;
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 = `
<div class="signal-card-header">
<div class="signal-card-badges">
<span class="signal-proto-badge acars">ACARS</span>
<span class="signal-freq-badge">${escapeHtml(msg.flight || msg.tail || 'Unknown')}</span>
</div>
${status !== 'baseline' ? `
<span class="signal-status-pill" data-status="${status}">
<span class="status-dot"></span>
${status.charAt(0).toUpperCase() + status.slice(1)}
</span>
` : ''}
</div>
<div class="signal-card-body">
<div class="signal-meta-row">
${msg.label ? `<span class="signal-msg-type">${escapeHtml(msg.label)}</span>` : ''}
${seenCount > 1 ? `<span class="signal-seen-count">×${seenCount}</span>` : ''}
<span class="signal-timestamp" data-timestamp="${escapeHtml(msg.timestamp)}">${escapeHtml(relativeTime)}</span>
</div>
${msg.text ? `
<div class="signal-message">${escapeHtml(msg.text)}</div>
` : ''}
</div>
<div class="signal-card-footer">
<button class="signal-advanced-toggle" onclick="SignalCards.toggleAdvanced(this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
Details
</button>
<div class="signal-card-actions">
<button class="signal-action-btn" onclick="SignalCards.copyMessage(this)">Copy</button>
</div>
</div>
<div class="signal-advanced-panel">
<div class="signal-advanced-inner">
<div class="signal-advanced-content">
<div class="signal-advanced-section">
<div class="signal-advanced-title">Flight Details</div>
<div class="signal-advanced-grid">
${msg.flight ? `
<div class="signal-advanced-item">
<span class="signal-advanced-label">Flight</span>
<span class="signal-advanced-value">${escapeHtml(msg.flight)}</span>
</div>
` : ''}
${msg.tail ? `
<div class="signal-advanced-item">
<span class="signal-advanced-label">Tail #</span>
<span class="signal-advanced-value">${escapeHtml(msg.tail)}</span>
</div>
` : ''}
${msg.label ? `
<div class="signal-advanced-item">
<span class="signal-advanced-label">Label</span>
<span class="signal-advanced-value">${escapeHtml(msg.label)}</span>
</div>
` : ''}
${msg.mode ? `
<div class="signal-advanced-item">
<span class="signal-advanced-label">Mode</span>
<span class="signal-advanced-value">${escapeHtml(msg.mode)}</span>
</div>
` : ''}
${msg.frequency ? `
<div class="signal-advanced-item">
<span class="signal-advanced-label">Frequency</span>
<span class="signal-advanced-value">${msg.frequency} MHz</span>
</div>
` : ''}
</div>
</div>
</div>
</div>
</div>
`;
return card;
}
/**
* 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
let meterTypeClass = 'electric';
const meterType = (msg.type || '').toLowerCase();
if (meterType.includes('gas')) {
meterTypeClass = 'gas';
} else if (meterType.includes('water')) {
meterTypeClass = 'water';
}
card.innerHTML = `
<div class="signal-card-header">
<div class="signal-card-badges">
<span class="signal-proto-badge meter ${meterTypeClass}">${escapeHtml(msg.type || 'Meter')}</span>
<span class="signal-freq-badge">ID: ${escapeHtml(msg.id || 'N/A')}</span>
</div>
${status !== 'baseline' ? `
<span class="signal-status-pill" data-status="${status}">
<span class="status-dot"></span>
${status.charAt(0).toUpperCase() + status.slice(1)}
</span>
` : ''}
</div>
<div class="signal-card-body">
<div class="signal-meta-row">
${msg.endpoint_type ? `<span class="signal-msg-type">${escapeHtml(msg.endpoint_type)}</span>` : ''}
${seenCount > 1 ? `<span class="signal-seen-count">×${seenCount}</span>` : ''}
<span class="signal-timestamp" data-timestamp="${escapeHtml(msg.timestamp)}">${escapeHtml(relativeTime)}</span>
</div>
<div class="signal-meter-data">
${msg.consumption !== undefined ? `
<div class="signal-meter-reading">
<span class="meter-label">Consumption</span>
<span class="meter-value">${msg.consumption.toLocaleString()} ${msg.unit || 'units'}</span>
</div>
` : ''}
</div>
</div>
<div class="signal-card-footer">
<button class="signal-advanced-toggle" onclick="SignalCards.toggleAdvanced(this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
Details
</button>
<div class="signal-card-actions">
<button class="signal-action-btn" onclick="SignalCards.muteAddress('${escapeHtml(msg.id)}')">Mute</button>
</div>
</div>
<div class="signal-advanced-panel">
<div class="signal-advanced-inner">
<div class="signal-advanced-content">
<div class="signal-advanced-section">
<div class="signal-advanced-title">Meter Details</div>
<div class="signal-advanced-grid">
<div class="signal-advanced-item">
<span class="signal-advanced-label">Meter ID</span>
<span class="signal-advanced-value">${escapeHtml(msg.id || 'N/A')}</span>
</div>
<div class="signal-advanced-item">
<span class="signal-advanced-label">Type</span>
<span class="signal-advanced-value">${escapeHtml(msg.type || 'Unknown')}</span>
</div>
${msg.endpoint_type ? `
<div class="signal-advanced-item">
<span class="signal-advanced-label">Endpoint</span>
<span class="signal-advanced-value">${escapeHtml(msg.endpoint_type)}</span>
</div>
` : ''}
${msg.endpoint_id ? `
<div class="signal-advanced-item">
<span class="signal-advanced-label">Endpoint ID</span>
<span class="signal-advanced-value">${escapeHtml(msg.endpoint_id)}</span>
</div>
` : ''}
<div class="signal-advanced-item">
<span class="signal-advanced-label">Seen</span>
<span class="signal-advanced-value">${seenCount} time${seenCount > 1 ? 's' : ''}</span>
</div>
</div>
</div>
</div>
</div>
</div>
`;
return card;
}
/**
* 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('Message copied to clipboard');
}).catch(() => {
showToast('Failed to copy', '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(`Address ${address} muted`);
// 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);
}
/**
* 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(`Showing ${label} on map`);
}
/**
* 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 = `
<span class="signal-filter-label">Status</span>
<button class="signal-filter-btn active" data-filter="status" data-value="all">
<span class="filter-dot"></span>
All
<span class="signal-filter-count" data-count="all">0</span>
</button>
<button class="signal-filter-btn" data-filter="status" data-value="new">
<span class="filter-dot"></span>
New
<span class="signal-filter-count" data-count="new">0</span>
</button>
<button class="signal-filter-btn" data-filter="status" data-value="repeated">
<span class="filter-dot"></span>
Repeated
<span class="signal-filter-count" data-count="repeated">0</span>
</button>
<button class="signal-filter-btn" data-filter="status" data-value="burst">
<span class="filter-dot"></span>
Burst
<span class="signal-filter-count" data-count="burst">0</span>
</button>
<span class="signal-filter-divider"></span>
<span class="signal-filter-label">Protocol</span>
<button class="signal-filter-btn protocol-btn active" data-filter="protocol" data-value="all">All</button>
<button class="signal-filter-btn protocol-btn" data-filter="protocol" data-value="pocsag">POCSAG</button>
<button class="signal-filter-btn protocol-btn" data-filter="protocol" data-value="flex">FLEX</button>
<span class="signal-filter-divider"></span>
<span class="signal-filter-label">Type</span>
<button class="signal-filter-btn type-btn active" data-filter="msgType" data-value="all">All</button>
<button class="signal-filter-btn type-btn" data-filter="msgType" data-value="alpha">Alpha</button>
<button class="signal-filter-btn type-btn" data-filter="msgType" data-value="numeric">Numeric</button>
<button class="signal-filter-btn type-btn" data-filter="msgType" data-value="tone">Tone</button>
<span class="signal-filter-divider"></span>
<div class="signal-search-container">
<input type="text" class="signal-search-input" id="pagerSearchInput" placeholder="Search address or content..." />
</div>
`;
// 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 = `
<button class="signal-filter-btn active" data-filter="status" data-value="all">
<span class="filter-dot"></span>
All
<span class="signal-filter-count" data-count="all">0</span>
</button>
<button class="signal-filter-btn" data-filter="status" data-value="new">
<span class="filter-dot"></span>
New
<span class="signal-filter-count" data-count="new">0</span>
</button>
<button class="signal-filter-btn" data-filter="status" data-value="repeated">
<span class="filter-dot"></span>
Repeated
<span class="signal-filter-count" data-count="repeated">0</span>
</button>
<span class="signal-filter-divider"></span>
<span class="signal-filter-label">Type</span>
<button class="signal-filter-btn type-btn active" data-filter="packetType" data-value="all">All</button>
<button class="signal-filter-btn type-btn" data-filter="packetType" data-value="position">Position</button>
<button class="signal-filter-btn type-btn" data-filter="packetType" data-value="weather">Weather</button>
<button class="signal-filter-btn type-btn" data-filter="packetType" data-value="message">Message</button>
<div class="signal-search-container">
<input type="text" class="signal-search-input" id="aprsSearchInput" placeholder="Search callsign..." />
</div>
`;
// 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 = `
<span class="signal-filter-label">Status</span>
<button class="signal-filter-btn active" data-filter="status" data-value="all">
<span class="filter-dot"></span>
All
<span class="signal-filter-count" data-count="all">0</span>
</button>
<button class="signal-filter-btn" data-filter="status" data-value="new">
<span class="filter-dot"></span>
New
<span class="signal-filter-count" data-count="new">0</span>
</button>
<button class="signal-filter-btn" data-filter="status" data-value="repeated">
<span class="filter-dot"></span>
Repeated
<span class="signal-filter-count" data-count="repeated">0</span>
</button>
<button class="signal-filter-btn" data-filter="status" data-value="burst">
<span class="filter-dot"></span>
Burst
<span class="signal-filter-count" data-count="burst">0</span>
</button>
<span class="signal-filter-divider"></span>
<div class="signal-search-container">
<input type="text" class="signal-search-input" id="sensorSearchInput" placeholder="Search model or ID..." />
</div>
`;
// 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,
// Signal classification
SignalClassification,
createSignalIndicator,
createSignalAssessmentPanel,
// UI interactions
toggleAdvanced,
copyMessage,
muteAddress,
isAddressMuted,
showOnMap,
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;