Add clickable station badges and integrate signal guessing engine

- Add clickable APRS station badges that display raw packet data in a modal
- Integrate SignalGuess into sensor mode cards for frequency identification
- Standardize UI language across timeline and signal components
- Update frequency band naming for consistency (e.g., "Wi-Fi 2.4GHz" → "2.4 GHz wireless band")

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-01-20 23:59:08 +00:00
parent e8f23a7571
commit e5c9142742
6 changed files with 373 additions and 144 deletions
+14 -14
View File
@@ -179,15 +179,15 @@ const ActivityTimeline = (function() {
if (mode === 'rf' || mode === 'tscm' || mode === 'listening-post') {
const f = parseFloat(id);
if (!isNaN(f)) {
if (f >= 2400 && f <= 2500) return 'Wi-Fi 2.4GHz';
if (f >= 5150 && f <= 5850) return 'Wi-Fi 5GHz';
if (f >= 433 && f <= 434) return '433MHz ISM';
if (f >= 868 && f <= 869) return '868MHz ISM';
if (f >= 902 && f <= 928) return '915MHz ISM';
if (f >= 2400 && f <= 2500) return '2.4 GHz wireless band';
if (f >= 5150 && f <= 5850) return '5 GHz wireless band';
if (f >= 433 && f <= 434) return '433 MHz low-power band';
if (f >= 868 && f <= 869) return '868 MHz low-power band';
if (f >= 902 && f <= 928) return '915 MHz low-power band';
if (f >= 315 && f <= 316) return '315MHz';
if (f >= 2402 && f <= 2480) return 'Bluetooth';
if (f >= 144 && f <= 148) return 'VHF Ham';
if (f >= 420 && f <= 450) return 'UHF Ham';
if (f >= 2402 && f <= 2480) return 'Bluetooth band';
if (f >= 144 && f <= 148) return 'VHF amateur band';
if (f >= 420 && f <= 450) return 'UHF amateur band';
return `${f.toFixed(3)} MHz`;
}
}
@@ -231,7 +231,7 @@ const ActivityTimeline = (function() {
item.firstSeen = now;
state.items.set(id, item);
addAnnotation(state, 'new', `New: ${item.label}`, now);
addAnnotation(state, 'new', `New activity: ${item.label}`, now);
}
// Add event
@@ -341,7 +341,7 @@ const ActivityTimeline = (function() {
if (item.pattern !== patternStr) {
item.pattern = patternStr;
addAnnotation(state, 'pattern', `Pattern: ${patternStr} - ${item.label}`, Date.now());
addAnnotation(state, 'pattern', `Repeating pattern observed: ${patternStr} - ${item.label}`, Date.now());
}
}
}
@@ -376,7 +376,7 @@ const ActivityTimeline = (function() {
item.status = item.flagged ? 'flagged' : 'new';
addAnnotation(state,
'flagged',
item.flagged ? `Flagged: ${item.label}` : `Unflagged: ${item.label}`,
item.flagged ? `Marked for review: ${item.label}` : `Review mark removed: ${item.label}`,
Date.now()
);
@@ -398,7 +398,7 @@ const ActivityTimeline = (function() {
const item = state.items.get(id);
if (item && item.status !== 'gone') {
item.status = 'gone';
addAnnotation(state, 'gone', `Inactive: ${item.label}`, Date.now());
addAnnotation(state, 'gone', `No longer active: ${item.label}`, Date.now());
}
}
@@ -479,7 +479,7 @@ const ActivityTimeline = (function() {
<div class="activity-timeline-empty">
<div class="activity-timeline-empty-icon">◯</div>
<div>No activity recorded</div>
<div style="margin-top: 4px; font-size: 9px;">Events will appear as they are detected</div>
<div style="margin-top: 4px; font-size: 9px;">Activity will appear here as events are observed</div>
</div>
</div>
<div class="activity-timeline-annotations" style="display: none;"></div>
@@ -737,7 +737,7 @@ const ActivityTimeline = (function() {
<div class="activity-timeline-empty">
<div class="activity-timeline-empty-icon">◯</div>
<div>No activity recorded</div>
<div style="margin-top: 4px; font-size: 9px;">Events will appear as they are detected</div>
<div style="margin-top: 4px; font-size: 9px;">Activity will appear here as events are observed</div>
</div>
`;
} else {
+124 -26
View File
@@ -26,8 +26,8 @@ const SignalCards = (function() {
STRENGTH_INFO: {
minimal: {
label: 'Minimal',
description: 'At detection threshold',
interpretation: 'may be ambient noise or distant source',
description: 'Near minimum observable level',
interpretation: 'may represent background activity or a distant source',
confidence: 'low',
color: '#888888',
icon: 'signal-0',
@@ -35,8 +35,8 @@ const SignalCards = (function() {
},
weak: {
label: 'Weak',
description: 'Detectable signal',
interpretation: 'potentially distant or obstructed',
description: 'Low-level signal present',
interpretation: 'possibly distant or partially obstructed',
confidence: 'low',
color: '#6baed6',
icon: 'signal-1',
@@ -44,7 +44,7 @@ const SignalCards = (function() {
},
moderate: {
label: 'Moderate',
description: 'Consistent presence',
description: 'Consistent signal presence',
interpretation: 'likely in proximity',
confidence: 'medium',
color: '#3182bd',
@@ -53,8 +53,8 @@ const SignalCards = (function() {
},
strong: {
label: 'Strong',
description: 'Clear signal',
interpretation: 'probable close proximity',
description: 'Clear, consistent signal',
interpretation: 'suggests relatively close proximity',
confidence: 'medium',
color: '#fd8d3c',
icon: 'signal-3',
@@ -62,8 +62,8 @@ const SignalCards = (function() {
},
very_strong: {
label: 'Very Strong',
description: 'High signal level',
interpretation: 'indicates likely nearby source',
description: 'Elevated signal level',
interpretation: 'consistent with a nearby source',
confidence: 'high',
color: '#e6550d',
icon: 'signal-4',
@@ -82,23 +82,23 @@ const SignalCards = (function() {
DURATION_INFO: {
transient: {
label: 'Transient',
modifier: 'briefly observed',
confidence_impact: 'reduces confidence'
modifier: 'observed briefly',
confidence_impact: 'limits assessment confidence'
},
short: {
label: 'Short-duration',
modifier: 'observed for a short period',
confidence_impact: 'limited confidence'
confidence_impact: 'provides limited confidence'
},
sustained: {
label: 'Sustained',
modifier: 'observed over sustained period',
confidence_impact: 'supports confidence'
confidence_impact: 'supports assessment confidence'
},
persistent: {
label: 'Persistent',
modifier: 'continuously observed',
confidence_impact: 'increases confidence'
confidence_impact: 'strengthens assessment confidence'
}
},
@@ -190,11 +190,11 @@ const SignalCards = (function() {
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`;
return `${strengthInfo.label}, ${durationInfo.label.toLowerCase()} signal with characteristics that suggest a transmitting device may be nearby`;
} else if (confidence === 'medium') {
return `${strengthInfo.label}, ${durationInfo.label.toLowerCase()} signal that may indicate device activity`;
return `${strengthInfo.label}, ${durationInfo.label.toLowerCase()} signal that may indicate nearby device activity`;
} else {
return `${durationInfo.modifier.charAt(0).toUpperCase() + durationInfo.modifier.slice(1)} ${strengthInfo.label.toLowerCase()} signal consistent with possible device presence`;
return `${durationInfo.modifier.charAt(0).toUpperCase() + durationInfo.modifier.slice(1)} ${strengthInfo.label.toLowerCase()} signal consistent with possible nearby device activity`;
}
},
@@ -207,11 +207,11 @@ const SignalCards = (function() {
const base = strengthInfo.interpretation;
if (confidence === 'high') {
return `Observed signal characteristics suggest ${base}`;
return `Signal characteristics suggest ${base}`;
} else if (confidence === 'medium') {
return `Signal pattern may indicate ${base}`;
return `Observed pattern may indicate ${base}`;
} else {
return `Limited data; signal could represent ${base} or environmental factors`;
return `With limited data, this signal may represent ${base} or environmental factors`;
}
},
@@ -246,7 +246,7 @@ const SignalCards = (function() {
estimate,
rangeMin,
rangeMax,
disclaimer: 'Range estimates are approximate and affected by walls, interference, and transmit power'
disclaimer: 'Range estimates are approximate and influenced by physical obstructions, interference, and transmitter power'
};
}
};
@@ -702,7 +702,7 @@ const SignalCards = (function() {
<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>
<span class="signal-freq-badge signal-station-clickable" data-callsign="${escapeHtml(msg.callsign || 'Unknown')}" data-raw="${msg.raw ? escapeHtml(msg.raw) : ''}" onclick="SignalCards.showStationRawData(this)" title="Click to view raw data">${escapeHtml(msg.callsign || 'Unknown')}</span>
</div>
${status !== 'baseline' ? `
<span class="signal-status-pill" data-status="${status}">
@@ -833,12 +833,44 @@ const SignalCards = (function() {
? createSignalIndicator(rssi, { compact: true })
: '<span class="signal-strength-indicator compact no-data" title="No signal data available">--</span>';
// Signal type guessing based on frequency
let signalGuess = null;
let signalGuessBadge = '';
let signalGuessSection = '';
if (msg.frequency && typeof SignalGuess !== 'undefined') {
const frequencyHz = parseFloat(msg.frequency) * 1_000_000; // Convert MHz to Hz
signalGuess = SignalGuess.guessSignalType({
frequency_hz: frequencyHz,
modulation: msg.modulation || null,
bandwidth_hz: msg.bandwidth ? parseFloat(msg.bandwidth) * 1000 : null,
rssi_dbm: rssi,
region: 'UK/EU'
});
// Create compact badge for header
if (signalGuess && signalGuess.primary_label !== 'Unknown Signal') {
signalGuessBadge = SignalGuess.createCompactBadge(signalGuess).outerHTML;
}
// Create detailed section for advanced panel
if (signalGuess) {
const guessElement = SignalGuess.createGuessElement(signalGuess, { showAlternatives: true, compact: false });
signalGuessSection = `
<div class="signal-advanced-section signal-guess-section">
<div class="signal-advanced-title">Signal Identification</div>
<div class="signal-guess-content"></div>
</div>
`;
}
}
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}
${signalGuessBadge}
</div>
${status !== 'baseline' ? `
<span class="signal-status-pill" data-status="${status}">
@@ -913,6 +945,7 @@ const SignalCards = (function() {
<div class="signal-advanced-inner">
<div class="signal-advanced-content">
${rssi !== null ? createSignalAssessmentPanel(rssi, stats?.lastSeen ? (Date.now() - stats.firstSeen) / 1000 : null, seenCount) : ''}
${signalGuessSection}
<div class="signal-advanced-section">
<div class="signal-advanced-title">Sensor Details</div>
<div class="signal-advanced-grid">
@@ -953,6 +986,15 @@ const SignalCards = (function() {
</div>
`;
// Populate signal guess content if available
if (signalGuess) {
const guessContentDiv = card.querySelector('.signal-guess-content');
if (guessContentDiv) {
const guessElement = SignalGuess.createGuessElement(signalGuess, { showAlternatives: true, compact: false });
guessContentDiv.appendChild(guessElement);
}
}
return card;
}
@@ -1178,9 +1220,9 @@ const SignalCards = (function() {
const message = card.querySelector('.signal-message');
if (message) {
navigator.clipboard.writeText(message.textContent).then(() => {
showToast('Message copied to clipboard');
showToast('Content copied');
}).catch(() => {
showToast('Failed to copy', 'error');
showToast('Unable to copy content', 'error');
});
}
}
@@ -1193,7 +1235,7 @@ const SignalCards = (function() {
if (!muted.includes(address)) {
muted.push(address);
localStorage.setItem('mutedAddresses', JSON.stringify(muted));
showToast(`Address ${address} muted`);
showToast(`Source ${address} hidden from view`);
// 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 => {
@@ -1221,7 +1263,62 @@ const SignalCards = (function() {
detail: { lat, lon, label }
});
document.dispatchEvent(event);
showToast(`Showing ${label} on map`);
showToast(`Displaying ${label} location`);
}
/**
* Show raw data modal for a station
*/
function showStationRawData(element) {
const callsign = element.dataset.callsign || 'Unknown';
const rawData = element.dataset.raw || '';
// Create or reuse modal
let modal = document.getElementById('stationRawDataModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'stationRawDataModal';
modal.className = 'station-raw-modal';
modal.innerHTML = `
<div class="station-raw-modal-backdrop"></div>
<div class="station-raw-modal-content">
<div class="station-raw-modal-header">
<span class="station-raw-modal-title"></span>
<button class="station-raw-modal-close">&times;</button>
</div>
<div class="station-raw-modal-body">
<div class="station-raw-label">Raw Packet Data</div>
<pre class="station-raw-data-display"></pre>
</div>
<div class="station-raw-modal-footer">
<button class="station-raw-copy-btn">Copy to Clipboard</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Close handlers
modal.querySelector('.station-raw-modal-backdrop').addEventListener('click', () => {
modal.classList.remove('show');
});
modal.querySelector('.station-raw-modal-close').addEventListener('click', () => {
modal.classList.remove('show');
});
modal.querySelector('.station-raw-copy-btn').addEventListener('click', () => {
const rawText = modal.querySelector('.station-raw-data-display').textContent;
navigator.clipboard.writeText(rawText).then(() => {
showToast('Raw data copied to clipboard');
}).catch(() => {
showToast('Failed to copy', 'error');
});
});
}
// Populate modal
modal.querySelector('.station-raw-modal-title').textContent = `Station: ${callsign}`;
modal.querySelector('.station-raw-data-display').textContent = rawData || 'No raw data available';
// Show modal
modal.classList.add('show');
}
/**
@@ -1687,6 +1784,7 @@ const SignalCards = (function() {
muteAddress,
isAddressMuted,
showOnMap,
showStationRawData,
showToast,
// Filter bar
+18 -18
View File
@@ -61,15 +61,15 @@ const SignalTimeline = (function() {
*/
function categorizeFrequency(freq) {
const f = parseFloat(freq);
if (f >= 2400 && f <= 2500) return 'Wi-Fi 2.4GHz';
if (f >= 5150 && f <= 5850) return 'Wi-Fi 5GHz';
if (f >= 433 && f <= 434) return '433MHz ISM';
if (f >= 868 && f <= 869) return '868MHz ISM';
if (f >= 902 && f <= 928) return '915MHz ISM';
if (f >= 2400 && f <= 2500) return '2.4 GHz wireless band';
if (f >= 5150 && f <= 5850) return '5 GHz wireless band';
if (f >= 433 && f <= 434) return '433 MHz low-power band';
if (f >= 868 && f <= 869) return '868 MHz low-power band';
if (f >= 902 && f <= 928) return '915 MHz low-power band';
if (f >= 315 && f <= 316) return '315MHz';
if (f >= 2402 && f <= 2480) return 'Bluetooth';
if (f >= 144 && f <= 148) return 'VHF Ham';
if (f >= 420 && f <= 450) return 'UHF Ham';
if (f >= 2402 && f <= 2480) return 'Bluetooth band';
if (f >= 144 && f <= 148) return 'VHF amateur band';
if (f >= 420 && f <= 450) return 'UHF amateur band';
return `${freq} MHz`;
}
@@ -86,7 +86,7 @@ const SignalTimeline = (function() {
state.signals.set(frequency, signal);
// Add annotation for new signal
addAnnotation('new', `New signal detected: ${signal.name}`, now);
addAnnotation('new', `New signal observed: ${signal.name}`, now);
}
// Add event
@@ -152,7 +152,7 @@ const SignalTimeline = (function() {
if (signal.status !== 'burst') {
signal.status = 'burst';
addAnnotation('burst',
`Burst: ${recentEvents.length} transmissions in ${config.burstWindow/1000}s - ${signal.name}`,
`Activity cluster: ${recentEvents.length} events in ${config.burstWindow/1000}s - ${signal.name}`,
now
);
}
@@ -200,7 +200,7 @@ const SignalTimeline = (function() {
if (signal.pattern !== patternStr) {
signal.pattern = patternStr;
addAnnotation('pattern',
`Pattern detected: ${patternStr} - ${signal.name}`,
`Repeating pattern observed: ${patternStr} - ${signal.name}`,
Date.now()
);
}
@@ -235,8 +235,8 @@ const SignalTimeline = (function() {
signal.status = signal.flagged ? 'flagged' : 'new';
addAnnotation('flagged',
signal.flagged
? `Flagged for investigation: ${signal.name}`
: `Unflagged: ${signal.name}`,
? `Marked for review: ${signal.name}`
: `Review mark removed: ${signal.name}`,
Date.now()
);
}
@@ -249,7 +249,7 @@ const SignalTimeline = (function() {
const signal = state.signals.get(frequency);
if (signal && signal.status !== 'gone') {
signal.status = 'gone';
addAnnotation('gone', `Signal disappeared: ${signal.name}`, Date.now());
addAnnotation('gone', `Signal no longer observed: ${signal.name}`, Date.now());
}
}
@@ -313,8 +313,8 @@ const SignalTimeline = (function() {
<div class="signal-timeline-lanes" id="timelineLanes">
<div class="signal-timeline-empty">
<div class="signal-timeline-empty-icon">📡</div>
<div>No signals recorded yet</div>
<div style="margin-top: 4px; font-size: 9px;">Signals will appear as they are detected</div>
<div>No signal activity recorded</div>
<div style="margin-top: 4px; font-size: 9px;">Activity will appear here as signals are observed</div>
</div>
</div>
<div class="signal-timeline-annotations" id="timelineAnnotations" style="display: none;"></div>
@@ -548,8 +548,8 @@ const SignalTimeline = (function() {
lanesContainer.innerHTML = `
<div class="signal-timeline-empty">
<div class="signal-timeline-empty-icon">📡</div>
<div>No signals recorded yet</div>
<div style="margin-top: 4px; font-size: 9px;">Signals will appear as they are detected</div>
<div>No signal activity recorded</div>
<div style="margin-top: 4px; font-size: 9px;">Activity will appear here as signals are observed</div>
</div>
`;
} else {