mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 22:59:59 -07:00
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>
1721 lines
74 KiB
JavaScript
1721 lines
74 KiB
JavaScript
/**
|
||
* 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;
|