mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 22:59:59 -07:00
Implements heuristic-based signal identification that provides plain-English guesses for detected signals based on frequency, modulation, bandwidth, and burst behavior. Features: - Python backend engine (utils/signal_guess.py) - JavaScript client-side engine with UI components - Hedged language output (never claims certainty) - UK/EU and US region support - Confidence levels (LOW/MEDIUM/HIGH) - 50+ unit tests for deterministic verification Supported signal types: FM broadcast, airband, cellular/LTE, ISM bands (433/868/915/2.4GHz), TPMS, amateur radio, marine VHF, DAB, pager networks, weather satellites, ADS-B, and more. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1008 lines
37 KiB
JavaScript
1008 lines
37 KiB
JavaScript
/**
|
|
* Signal Guessing Engine - Client-side Implementation
|
|
*
|
|
* Heuristic-based signal identification that provides plain-English guesses
|
|
* for detected signals. Uses hedged language - never claims certainty.
|
|
*
|
|
* Matching Python implementation in utils/signal_guess.py
|
|
*/
|
|
|
|
const SignalGuess = (function() {
|
|
'use strict';
|
|
|
|
// ==========================================================================
|
|
// Confidence Levels
|
|
// ==========================================================================
|
|
|
|
const Confidence = {
|
|
LOW: 'LOW',
|
|
MEDIUM: 'MEDIUM',
|
|
HIGH: 'HIGH'
|
|
};
|
|
|
|
// Confidence badge colors
|
|
const CONFIDENCE_COLORS = {
|
|
LOW: '#888888',
|
|
MEDIUM: '#f0ad4e',
|
|
HIGH: '#5cb85c'
|
|
};
|
|
|
|
// ==========================================================================
|
|
// Signal Type Definitions
|
|
// ==========================================================================
|
|
|
|
// All frequencies in Hz
|
|
const SIGNAL_TYPES = [
|
|
// FM Broadcast Radio
|
|
{
|
|
label: 'FM Broadcast Radio',
|
|
tags: ['broadcast', 'commercial', 'wideband'],
|
|
description: 'Commercial FM radio station transmission',
|
|
frequencyRanges: [[87500000, 108000000]],
|
|
modulationHints: ['WFM', 'FM', 'WBFM'],
|
|
bandwidthRange: [150000, 250000],
|
|
baseScore: 15,
|
|
isBurstType: false,
|
|
regions: ['UK/EU', 'US', 'GLOBAL']
|
|
},
|
|
|
|
// Civil Aviation / Airband
|
|
{
|
|
label: 'Airband (Civil Aviation Voice)',
|
|
tags: ['aviation', 'voice', 'aeronautical'],
|
|
description: 'Civil aviation voice communications',
|
|
frequencyRanges: [[118000000, 137000000]],
|
|
modulationHints: ['AM', 'A3E'],
|
|
bandwidthRange: [6000, 10000],
|
|
baseScore: 15,
|
|
isBurstType: false,
|
|
regions: ['UK/EU', 'US', 'GLOBAL']
|
|
},
|
|
|
|
// ISM 433 MHz (EU)
|
|
{
|
|
label: 'ISM Device (433 MHz)',
|
|
tags: ['ism', 'short-range', 'telemetry', 'consumer'],
|
|
description: 'Industrial, Scientific, Medical band device',
|
|
frequencyRanges: [[433050000, 434790000]],
|
|
modulationHints: ['OOK', 'ASK', 'FSK', 'NFM', 'FM'],
|
|
bandwidthRange: [10000, 50000],
|
|
baseScore: 12,
|
|
isBurstType: true,
|
|
regions: ['UK/EU']
|
|
},
|
|
|
|
// ISM 315 MHz (US)
|
|
{
|
|
label: 'ISM Device (315 MHz)',
|
|
tags: ['ism', 'short-range', 'telemetry', 'consumer'],
|
|
description: 'Industrial, Scientific, Medical band device (US)',
|
|
frequencyRanges: [[315000000, 316000000]],
|
|
modulationHints: ['OOK', 'ASK', 'FSK'],
|
|
bandwidthRange: [10000, 50000],
|
|
baseScore: 12,
|
|
isBurstType: true,
|
|
regions: ['US']
|
|
},
|
|
|
|
// ISM 868 MHz (EU)
|
|
{
|
|
label: 'ISM Device (868 MHz)',
|
|
tags: ['ism', 'short-range', 'telemetry', 'iot'],
|
|
description: '868 MHz ISM band device (LoRa, sensors, IoT)',
|
|
frequencyRanges: [[868000000, 868600000], [869400000, 869650000]],
|
|
modulationHints: ['FSK', 'GFSK', 'LoRa', 'OOK', 'NFM'],
|
|
bandwidthRange: [10000, 150000],
|
|
baseScore: 12,
|
|
isBurstType: true,
|
|
regions: ['UK/EU']
|
|
},
|
|
|
|
// ISM 915 MHz (US)
|
|
{
|
|
label: 'ISM Device (915 MHz)',
|
|
tags: ['ism', 'short-range', 'telemetry', 'iot'],
|
|
description: '915 MHz ISM band device (US/Americas)',
|
|
frequencyRanges: [[902000000, 928000000]],
|
|
modulationHints: ['FSK', 'GFSK', 'LoRa', 'OOK', 'NFM', 'FHSS'],
|
|
bandwidthRange: [10000, 500000],
|
|
baseScore: 12,
|
|
isBurstType: true,
|
|
regions: ['US']
|
|
},
|
|
|
|
// ISM 2.4 GHz
|
|
{
|
|
label: 'ISM Device (2.4 GHz)',
|
|
tags: ['ism', 'wifi', 'bluetooth', 'wireless'],
|
|
description: '2.4 GHz ISM band (WiFi, Bluetooth, wireless devices)',
|
|
frequencyRanges: [[2400000000, 2483500000]],
|
|
modulationHints: ['OFDM', 'DSSS', 'FHSS', 'GFSK', 'WiFi', 'BT'],
|
|
bandwidthRange: [1000000, 40000000],
|
|
baseScore: 10,
|
|
isBurstType: false,
|
|
regions: ['UK/EU', 'US', 'GLOBAL']
|
|
},
|
|
|
|
// ISM 5.8 GHz
|
|
{
|
|
label: 'ISM Device (5.8 GHz)',
|
|
tags: ['ism', 'wifi', 'wireless', 'video'],
|
|
description: '5.8 GHz ISM band (WiFi, video links, wireless devices)',
|
|
frequencyRanges: [[5725000000, 5875000000]],
|
|
modulationHints: ['OFDM', 'WiFi'],
|
|
bandwidthRange: [10000000, 80000000],
|
|
baseScore: 10,
|
|
isBurstType: false,
|
|
regions: ['UK/EU', 'US', 'GLOBAL']
|
|
},
|
|
|
|
// TPMS
|
|
{
|
|
label: 'TPMS / Vehicle Telemetry',
|
|
tags: ['telemetry', 'automotive', 'burst', 'tpms'],
|
|
description: 'Tire pressure monitoring or similar vehicle telemetry',
|
|
frequencyRanges: [[314900000, 315100000], [433800000, 434000000], [433900000, 433940000]],
|
|
modulationHints: ['OOK', 'ASK', 'FSK', 'NFM'],
|
|
bandwidthRange: [10000, 40000],
|
|
baseScore: 10,
|
|
isBurstType: true,
|
|
regions: ['UK/EU', 'US']
|
|
},
|
|
|
|
// Cellular
|
|
{
|
|
label: 'Cellular / Mobile Network',
|
|
tags: ['cellular', 'lte', 'mobile', 'wideband'],
|
|
description: 'Mobile network transmission (2G/3G/4G/5G)',
|
|
frequencyRanges: [
|
|
[791000000, 862000000],
|
|
[880000000, 960000000],
|
|
[1710000000, 1880000000],
|
|
[1920000000, 2170000000],
|
|
[2500000000, 2690000000],
|
|
[698000000, 756000000],
|
|
[824000000, 894000000],
|
|
[1850000000, 1995000000]
|
|
],
|
|
modulationHints: ['OFDM', 'QAM', 'LTE', '4G', '5G', 'GSM', 'UMTS'],
|
|
bandwidthRange: [200000, 20000000],
|
|
baseScore: 8,
|
|
isBurstType: false,
|
|
regions: ['UK/EU', 'US', 'GLOBAL']
|
|
},
|
|
|
|
// PMR446
|
|
{
|
|
label: 'PMR446 Radio',
|
|
tags: ['pmr', 'voice', 'handheld', 'license-free'],
|
|
description: 'License-free handheld radio communications',
|
|
frequencyRanges: [[446000000, 446200000]],
|
|
modulationHints: ['NFM', 'FM', 'DPMR', 'dPMR'],
|
|
bandwidthRange: [6250, 12500],
|
|
baseScore: 14,
|
|
isBurstType: false,
|
|
regions: ['UK/EU']
|
|
},
|
|
|
|
// Marine VHF
|
|
{
|
|
label: 'Marine VHF Radio',
|
|
tags: ['marine', 'maritime', 'voice', 'nautical'],
|
|
description: 'Marine VHF voice communications',
|
|
frequencyRanges: [[156000000, 162025000]],
|
|
modulationHints: ['NFM', 'FM'],
|
|
bandwidthRange: [12500, 25000],
|
|
baseScore: 14,
|
|
isBurstType: false,
|
|
regions: ['UK/EU', 'US', 'GLOBAL']
|
|
},
|
|
|
|
// Amateur 2m
|
|
{
|
|
label: 'Amateur Radio (2m)',
|
|
tags: ['amateur', 'ham', 'voice', 'vhf'],
|
|
description: 'Amateur radio 2-meter band',
|
|
frequencyRanges: [[144000000, 148000000]],
|
|
modulationHints: ['NFM', 'FM', 'SSB', 'USB', 'LSB', 'CW'],
|
|
bandwidthRange: [2400, 15000],
|
|
baseScore: 12,
|
|
isBurstType: false,
|
|
regions: ['UK/EU', 'US', 'GLOBAL']
|
|
},
|
|
|
|
// Amateur 70cm
|
|
{
|
|
label: 'Amateur Radio (70cm)',
|
|
tags: ['amateur', 'ham', 'voice', 'uhf'],
|
|
description: 'Amateur radio 70-centimeter band',
|
|
frequencyRanges: [[430000000, 440000000]],
|
|
modulationHints: ['NFM', 'FM', 'SSB', 'USB', 'LSB', 'CW', 'D-STAR', 'DMR'],
|
|
bandwidthRange: [2400, 15000],
|
|
baseScore: 12,
|
|
isBurstType: false,
|
|
regions: ['UK/EU', 'US', 'GLOBAL']
|
|
},
|
|
|
|
// DECT
|
|
{
|
|
label: 'DECT Cordless Phone',
|
|
tags: ['dect', 'cordless', 'telephony', 'consumer'],
|
|
description: 'Digital Enhanced Cordless Telecommunications',
|
|
frequencyRanges: [[1880000000, 1900000000], [1920000000, 1930000000]],
|
|
modulationHints: ['GFSK', 'DECT'],
|
|
bandwidthRange: [1728000, 1728000],
|
|
baseScore: 12,
|
|
isBurstType: false,
|
|
regions: ['UK/EU', 'US']
|
|
},
|
|
|
|
// DAB
|
|
{
|
|
label: 'DAB Digital Radio',
|
|
tags: ['broadcast', 'digital', 'dab', 'wideband'],
|
|
description: 'Digital Audio Broadcasting radio',
|
|
frequencyRanges: [[174000000, 240000000]],
|
|
modulationHints: ['OFDM', 'DAB', 'DAB+'],
|
|
bandwidthRange: [1500000, 1600000],
|
|
baseScore: 14,
|
|
isBurstType: false,
|
|
regions: ['UK/EU']
|
|
},
|
|
|
|
// Pager
|
|
{
|
|
label: 'Pager Network',
|
|
tags: ['pager', 'pocsag', 'flex', 'messaging'],
|
|
description: 'Paging network transmission (POCSAG/FLEX)',
|
|
frequencyRanges: [[153000000, 154000000], [466000000, 467000000], [929000000, 932000000]],
|
|
modulationHints: ['FSK', 'POCSAG', 'FLEX'],
|
|
bandwidthRange: [12500, 25000],
|
|
baseScore: 13,
|
|
isBurstType: false,
|
|
regions: ['UK/EU', 'US']
|
|
},
|
|
|
|
// Weather Satellite
|
|
{
|
|
label: 'Weather Satellite (NOAA)',
|
|
tags: ['satellite', 'weather', 'apt', 'noaa'],
|
|
description: 'NOAA weather satellite APT transmission',
|
|
frequencyRanges: [[137000000, 138000000]],
|
|
modulationHints: ['APT', 'FM', 'NFM'],
|
|
bandwidthRange: [34000, 40000],
|
|
baseScore: 14,
|
|
isBurstType: false,
|
|
regions: ['GLOBAL']
|
|
},
|
|
|
|
// ADS-B
|
|
{
|
|
label: 'ADS-B Aircraft Tracking',
|
|
tags: ['aviation', 'adsb', 'surveillance', 'tracking'],
|
|
description: 'Automatic Dependent Surveillance-Broadcast',
|
|
frequencyRanges: [[1090000000, 1090000000]],
|
|
modulationHints: ['PPM', 'ADSB'],
|
|
bandwidthRange: [1000000, 2000000],
|
|
baseScore: 15,
|
|
isBurstType: true,
|
|
regions: ['GLOBAL']
|
|
},
|
|
|
|
// LoRaWAN
|
|
{
|
|
label: 'LoRaWAN / LoRa Device',
|
|
tags: ['iot', 'lora', 'lpwan', 'telemetry'],
|
|
description: 'LoRa long-range IoT device',
|
|
frequencyRanges: [[863000000, 870000000], [902000000, 928000000]],
|
|
modulationHints: ['LoRa', 'CSS', 'FSK'],
|
|
bandwidthRange: [125000, 500000],
|
|
baseScore: 11,
|
|
isBurstType: true,
|
|
regions: ['UK/EU', 'US']
|
|
},
|
|
|
|
// Key Fob
|
|
{
|
|
label: 'Remote Control / Key Fob',
|
|
tags: ['remote', 'keyfob', 'automotive', 'burst', 'ism'],
|
|
description: 'Wireless remote control or vehicle key fob',
|
|
frequencyRanges: [[314900000, 315100000], [433050000, 434790000], [867000000, 869000000]],
|
|
modulationHints: ['OOK', 'ASK', 'FSK', 'rolling'],
|
|
bandwidthRange: [10000, 50000],
|
|
baseScore: 10,
|
|
isBurstType: true,
|
|
regions: ['UK/EU', 'US']
|
|
}
|
|
];
|
|
|
|
// ==========================================================================
|
|
// Signal Guessing Engine
|
|
// ==========================================================================
|
|
|
|
/**
|
|
* Guess the signal type based on detection parameters.
|
|
*
|
|
* @param {Object} detection - Detection parameters
|
|
* @param {number} detection.frequency_hz - Center frequency in Hz (required)
|
|
* @param {string} [detection.modulation] - Modulation type (e.g., "FM", "AM", "NFM")
|
|
* @param {number} [detection.bandwidth_hz] - Estimated bandwidth in Hz
|
|
* @param {number} [detection.duration_ms] - How long signal observed in ms
|
|
* @param {number} [detection.repetition_count] - How many times seen recently
|
|
* @param {number} [detection.rssi_dbm] - Signal strength in dBm
|
|
* @param {string} [detection.region="UK/EU"] - Region for frequency allocations
|
|
* @returns {Object} Result with primary_label, confidence, alternatives, explanation, tags
|
|
*/
|
|
function guessSignalType(detection) {
|
|
const {
|
|
frequency_hz,
|
|
modulation = null,
|
|
bandwidth_hz = null,
|
|
duration_ms = null,
|
|
repetition_count = null,
|
|
rssi_dbm = null,
|
|
region = 'UK/EU'
|
|
} = detection;
|
|
|
|
if (!frequency_hz || typeof frequency_hz !== 'number') {
|
|
return {
|
|
primary_label: 'Unknown Signal',
|
|
confidence: Confidence.LOW,
|
|
alternatives: [],
|
|
explanation: 'No frequency data provided.',
|
|
tags: ['unknown']
|
|
};
|
|
}
|
|
|
|
// Score all signal types
|
|
const scores = {};
|
|
const matchedTypes = {};
|
|
|
|
for (const signalType of SIGNAL_TYPES) {
|
|
const score = scoreSignalType(
|
|
signalType,
|
|
frequency_hz,
|
|
modulation,
|
|
bandwidth_hz,
|
|
duration_ms,
|
|
repetition_count,
|
|
region
|
|
);
|
|
if (score > 0) {
|
|
scores[signalType.label] = score;
|
|
matchedTypes[signalType.label] = signalType;
|
|
}
|
|
}
|
|
|
|
// No matches - return unknown
|
|
if (Object.keys(scores).length === 0) {
|
|
return {
|
|
primary_label: 'Unknown Signal',
|
|
confidence: Confidence.LOW,
|
|
alternatives: [],
|
|
explanation: buildUnknownExplanation(frequency_hz, modulation),
|
|
tags: ['unknown']
|
|
};
|
|
}
|
|
|
|
// Sort by score descending
|
|
const sortedLabels = Object.keys(scores).sort((a, b) => scores[b] - scores[a]);
|
|
|
|
// Primary guess
|
|
const primaryLabel = sortedLabels[0];
|
|
const primaryScore = scores[primaryLabel];
|
|
const primaryType = matchedTypes[primaryLabel];
|
|
|
|
// Calculate confidence
|
|
const confidence = calculateConfidence(
|
|
primaryScore,
|
|
scores,
|
|
sortedLabels,
|
|
modulation,
|
|
bandwidth_hz
|
|
);
|
|
|
|
// Build alternatives (up to 3)
|
|
const alternatives = sortedLabels.slice(1, 4).map(label => ({
|
|
label: label,
|
|
confidence: calculateAlternativeConfidence(scores[label], primaryScore, confidence)
|
|
}));
|
|
|
|
// Build explanation
|
|
const explanation = buildExplanation(
|
|
primaryType,
|
|
confidence,
|
|
frequency_hz,
|
|
modulation,
|
|
bandwidth_hz,
|
|
duration_ms,
|
|
repetition_count
|
|
);
|
|
|
|
return {
|
|
primary_label: primaryLabel,
|
|
confidence: confidence,
|
|
alternatives: alternatives,
|
|
explanation: explanation,
|
|
tags: [...primaryType.tags]
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculate score for a signal type match.
|
|
*/
|
|
function scoreSignalType(signalType, frequency_hz, modulation, bandwidth_hz, duration_ms, repetition_count, region) {
|
|
// Check region
|
|
if (!signalType.regions.includes(region) && !signalType.regions.includes('GLOBAL')) {
|
|
return 0;
|
|
}
|
|
|
|
// Check frequency match
|
|
let freqMatch = false;
|
|
for (const [freqMin, freqMax] of signalType.frequencyRanges) {
|
|
if (frequency_hz >= freqMin && frequency_hz <= freqMax) {
|
|
freqMatch = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!freqMatch) return 0;
|
|
|
|
// Base score
|
|
let score = signalType.baseScore;
|
|
|
|
// Modulation bonus
|
|
if (modulation) {
|
|
const modUpper = modulation.toUpperCase();
|
|
for (const hint of signalType.modulationHints) {
|
|
if (modUpper.includes(hint.toUpperCase()) || hint.toUpperCase().includes(modUpper)) {
|
|
score += 5;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bandwidth bonus/penalty
|
|
if (bandwidth_hz && signalType.bandwidthRange) {
|
|
const [bwMin, bwMax] = signalType.bandwidthRange;
|
|
if (bandwidth_hz >= bwMin && bandwidth_hz <= bwMax) {
|
|
score += 4;
|
|
} else if (bandwidth_hz < bwMin * 0.5 || bandwidth_hz > bwMax * 2) {
|
|
score -= 3;
|
|
}
|
|
}
|
|
|
|
// Burst behavior bonus
|
|
if (signalType.isBurstType) {
|
|
if (duration_ms !== null && duration_ms < 1000) {
|
|
score += 3;
|
|
}
|
|
if (repetition_count !== null && repetition_count >= 2) {
|
|
score += 2;
|
|
}
|
|
}
|
|
|
|
return Math.max(0, score);
|
|
}
|
|
|
|
/**
|
|
* Calculate confidence level.
|
|
*/
|
|
function calculateConfidence(primaryScore, allScores, sortedLabels, modulation, bandwidth_hz) {
|
|
if (sortedLabels.length === 1) {
|
|
if (primaryScore >= 18 && (modulation || bandwidth_hz)) {
|
|
return Confidence.HIGH;
|
|
} else if (primaryScore >= 14) {
|
|
return Confidence.MEDIUM;
|
|
}
|
|
return Confidence.LOW;
|
|
}
|
|
|
|
const secondScore = allScores[sortedLabels[1]];
|
|
const margin = primaryScore - secondScore;
|
|
|
|
if (primaryScore >= 18 && margin >= 5) {
|
|
return Confidence.HIGH;
|
|
} else if (primaryScore >= 14 && margin >= 3) {
|
|
return Confidence.MEDIUM;
|
|
} else if (primaryScore >= 12 && margin >= 2) {
|
|
return Confidence.MEDIUM;
|
|
}
|
|
return Confidence.LOW;
|
|
}
|
|
|
|
/**
|
|
* Calculate confidence for alternative.
|
|
*/
|
|
function calculateAlternativeConfidence(altScore, primaryScore, primaryConfidence) {
|
|
const scoreRatio = primaryScore > 0 ? altScore / primaryScore : 0;
|
|
|
|
if (scoreRatio >= 0.9) {
|
|
if (primaryConfidence === Confidence.HIGH) return Confidence.MEDIUM;
|
|
return primaryConfidence;
|
|
} else if (scoreRatio >= 0.7) {
|
|
if (primaryConfidence === Confidence.HIGH) return Confidence.MEDIUM;
|
|
return Confidence.LOW;
|
|
}
|
|
return Confidence.LOW;
|
|
}
|
|
|
|
/**
|
|
* Build hedged explanation.
|
|
*/
|
|
function buildExplanation(signalType, confidence, frequency_hz, modulation, bandwidth_hz, duration_ms, repetition_count) {
|
|
const freqMhz = (frequency_hz / 1000000).toFixed(3);
|
|
|
|
let explanation;
|
|
if (confidence === Confidence.HIGH) {
|
|
explanation = `Frequency of ${freqMhz} MHz is consistent with ${signalType.description.toLowerCase()}.`;
|
|
} else if (confidence === Confidence.MEDIUM) {
|
|
explanation = `Frequency of ${freqMhz} MHz could indicate ${signalType.description.toLowerCase()}.`;
|
|
} else {
|
|
explanation = `Frequency of ${freqMhz} MHz may be associated with ${signalType.description.toLowerCase()}.`;
|
|
}
|
|
|
|
// Supporting evidence
|
|
const evidence = [];
|
|
if (modulation) evidence.push(`${modulation} modulation`);
|
|
if (bandwidth_hz) evidence.push(`~${Math.round(bandwidth_hz / 1000)} kHz bandwidth`);
|
|
if (duration_ms !== null && duration_ms < 1000) evidence.push('short-burst pattern');
|
|
if (repetition_count !== null && repetition_count >= 3) evidence.push('repeated transmission');
|
|
|
|
if (evidence.length > 0) {
|
|
const evidenceStr = evidence.join(', ');
|
|
if (confidence === Confidence.HIGH) {
|
|
explanation += ` Observed characteristics (${evidenceStr}) support this identification.`;
|
|
} else {
|
|
explanation += ` Observed ${evidenceStr}.`;
|
|
}
|
|
}
|
|
|
|
return explanation;
|
|
}
|
|
|
|
/**
|
|
* Build unknown explanation.
|
|
*/
|
|
function buildUnknownExplanation(frequency_hz, modulation) {
|
|
const freqMhz = (frequency_hz / 1000000).toFixed(3);
|
|
if (modulation) {
|
|
return `Signal at ${freqMhz} MHz with ${modulation} modulation does not match common allocations for this region.`;
|
|
}
|
|
return `Signal at ${freqMhz} MHz does not match common allocations for this region. Additional characteristics may help identification.`;
|
|
}
|
|
|
|
// ==========================================================================
|
|
// UI Components
|
|
// ==========================================================================
|
|
|
|
/**
|
|
* Create a signal guess badge element.
|
|
*
|
|
* @param {Object} result - Result from guessSignalType
|
|
* @param {Object} [options] - Display options
|
|
* @param {boolean} [options.showAlternatives=true] - Show alternatives in expandable section
|
|
* @param {boolean} [options.compact=false] - Use compact display
|
|
* @returns {HTMLElement} The badge element
|
|
*/
|
|
function createGuessElement(result, options = {}) {
|
|
const { showAlternatives = true, compact = false } = options;
|
|
|
|
const container = document.createElement('div');
|
|
container.className = `signal-guess-container${compact ? ' compact' : ''}`;
|
|
|
|
// Primary label + confidence badge
|
|
const primaryRow = document.createElement('div');
|
|
primaryRow.className = 'signal-guess-primary';
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'signal-guess-label';
|
|
label.textContent = result.primary_label;
|
|
primaryRow.appendChild(label);
|
|
|
|
const badge = document.createElement('span');
|
|
badge.className = `signal-guess-confidence signal-guess-confidence-${result.confidence.toLowerCase()}`;
|
|
badge.textContent = result.confidence;
|
|
badge.style.backgroundColor = CONFIDENCE_COLORS[result.confidence];
|
|
primaryRow.appendChild(badge);
|
|
|
|
// "Why?" tooltip
|
|
const whyBtn = document.createElement('button');
|
|
whyBtn.className = 'signal-guess-why';
|
|
whyBtn.textContent = 'Why?';
|
|
whyBtn.title = result.explanation;
|
|
whyBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
showExplanationPopup(result, whyBtn);
|
|
});
|
|
primaryRow.appendChild(whyBtn);
|
|
|
|
container.appendChild(primaryRow);
|
|
|
|
// Tags (compact display)
|
|
if (!compact && result.tags && result.tags.length > 0) {
|
|
const tagsRow = document.createElement('div');
|
|
tagsRow.className = 'signal-guess-tags';
|
|
result.tags.slice(0, 3).forEach(tag => {
|
|
const tagEl = document.createElement('span');
|
|
tagEl.className = 'signal-guess-tag';
|
|
tagEl.textContent = tag;
|
|
tagsRow.appendChild(tagEl);
|
|
});
|
|
container.appendChild(tagsRow);
|
|
}
|
|
|
|
// Alternatives (expandable)
|
|
if (showAlternatives && result.alternatives && result.alternatives.length > 0) {
|
|
const altSection = document.createElement('div');
|
|
altSection.className = 'signal-guess-alternatives';
|
|
|
|
const altToggle = document.createElement('button');
|
|
altToggle.className = 'signal-guess-alt-toggle';
|
|
altToggle.innerHTML = `
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12">
|
|
<path d="M6 9l6 6 6-6"/>
|
|
</svg>
|
|
${result.alternatives.length} alternative${result.alternatives.length > 1 ? 's' : ''}
|
|
`;
|
|
|
|
const altList = document.createElement('div');
|
|
altList.className = 'signal-guess-alt-list';
|
|
altList.style.display = 'none';
|
|
|
|
result.alternatives.forEach(alt => {
|
|
const altItem = document.createElement('div');
|
|
altItem.className = 'signal-guess-alt-item';
|
|
altItem.innerHTML = `
|
|
<span class="signal-guess-alt-label">${escapeHtml(alt.label)}</span>
|
|
<span class="signal-guess-confidence signal-guess-confidence-${alt.confidence.toLowerCase()}"
|
|
style="background-color: ${CONFIDENCE_COLORS[alt.confidence]}">${alt.confidence}</span>
|
|
`;
|
|
altList.appendChild(altItem);
|
|
});
|
|
|
|
altToggle.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const isOpen = altList.style.display !== 'none';
|
|
altList.style.display = isOpen ? 'none' : 'block';
|
|
altToggle.classList.toggle('open', !isOpen);
|
|
});
|
|
|
|
altSection.appendChild(altToggle);
|
|
altSection.appendChild(altList);
|
|
container.appendChild(altSection);
|
|
}
|
|
|
|
return container;
|
|
}
|
|
|
|
/**
|
|
* Create a compact inline guess badge.
|
|
*/
|
|
function createCompactBadge(result) {
|
|
const badge = document.createElement('span');
|
|
badge.className = `signal-guess-badge signal-guess-badge-${result.confidence.toLowerCase()}`;
|
|
badge.title = result.explanation;
|
|
badge.innerHTML = `
|
|
<span class="signal-guess-badge-label">${escapeHtml(result.primary_label)}</span>
|
|
<span class="signal-guess-badge-conf" style="background-color: ${CONFIDENCE_COLORS[result.confidence]}">${result.confidence}</span>
|
|
`;
|
|
return badge;
|
|
}
|
|
|
|
/**
|
|
* Show explanation popup.
|
|
*/
|
|
function showExplanationPopup(result, anchorEl) {
|
|
// Remove existing popup
|
|
const existing = document.querySelector('.signal-guess-popup');
|
|
if (existing) existing.remove();
|
|
|
|
const popup = document.createElement('div');
|
|
popup.className = 'signal-guess-popup';
|
|
popup.innerHTML = `
|
|
<div class="signal-guess-popup-header">
|
|
<strong>Signal Identification</strong>
|
|
<button class="signal-guess-popup-close">×</button>
|
|
</div>
|
|
<div class="signal-guess-popup-body">
|
|
<div class="signal-guess-popup-primary">
|
|
<span class="signal-guess-label">${escapeHtml(result.primary_label)}</span>
|
|
<span class="signal-guess-confidence signal-guess-confidence-${result.confidence.toLowerCase()}"
|
|
style="background-color: ${CONFIDENCE_COLORS[result.confidence]}">${result.confidence}</span>
|
|
</div>
|
|
<p class="signal-guess-popup-explanation">${escapeHtml(result.explanation)}</p>
|
|
${result.tags && result.tags.length > 0 ? `
|
|
<div class="signal-guess-popup-tags">
|
|
${result.tags.map(t => `<span class="signal-guess-tag">${escapeHtml(t)}</span>`).join('')}
|
|
</div>
|
|
` : ''}
|
|
${result.alternatives && result.alternatives.length > 0 ? `
|
|
<div class="signal-guess-popup-alts">
|
|
<div class="signal-guess-popup-alts-title">Other possibilities:</div>
|
|
${result.alternatives.map(a => `
|
|
<div class="signal-guess-alt-item">
|
|
<span>${escapeHtml(a.label)}</span>
|
|
<span class="signal-guess-confidence signal-guess-confidence-${a.confidence.toLowerCase()}"
|
|
style="background-color: ${CONFIDENCE_COLORS[a.confidence]}">${a.confidence}</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
` : ''}
|
|
<div class="signal-guess-popup-disclaimer">
|
|
Note: Signal identification is based on frequency allocation patterns and observed characteristics.
|
|
Results are probabilistic and should not be considered definitive.
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(popup);
|
|
|
|
// Position near anchor
|
|
const rect = anchorEl.getBoundingClientRect();
|
|
popup.style.position = 'fixed';
|
|
popup.style.top = `${rect.bottom + 5}px`;
|
|
popup.style.left = `${Math.min(rect.left, window.innerWidth - 320)}px`;
|
|
|
|
// Close handlers
|
|
const closeBtn = popup.querySelector('.signal-guess-popup-close');
|
|
closeBtn.addEventListener('click', () => popup.remove());
|
|
|
|
// Close on outside click
|
|
setTimeout(() => {
|
|
document.addEventListener('click', function handler(e) {
|
|
if (!popup.contains(e.target)) {
|
|
popup.remove();
|
|
document.removeEventListener('click', handler);
|
|
}
|
|
});
|
|
}, 0);
|
|
}
|
|
|
|
/**
|
|
* Escape HTML for safe display.
|
|
*/
|
|
function escapeHtml(text) {
|
|
if (text === null || text === undefined) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = String(text);
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// ==========================================================================
|
|
// CSS Styles (inject on load)
|
|
// ==========================================================================
|
|
|
|
function injectStyles() {
|
|
if (document.getElementById('signal-guess-styles')) return;
|
|
|
|
const style = document.createElement('style');
|
|
style.id = 'signal-guess-styles';
|
|
style.textContent = `
|
|
.signal-guess-container {
|
|
font-size: 13px;
|
|
line-height: 1.4;
|
|
}
|
|
.signal-guess-container.compact {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
.signal-guess-primary {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.signal-guess-label {
|
|
font-weight: 500;
|
|
color: #e0e0e0;
|
|
}
|
|
.signal-guess-confidence {
|
|
display: inline-block;
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
color: #fff;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
.signal-guess-confidence-low { background-color: #888 !important; }
|
|
.signal-guess-confidence-medium { background-color: #f0ad4e !important; }
|
|
.signal-guess-confidence-high { background-color: #5cb85c !important; }
|
|
.signal-guess-why {
|
|
background: transparent;
|
|
border: 1px solid #555;
|
|
color: #999;
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
}
|
|
.signal-guess-why:hover {
|
|
border-color: #00d4ff;
|
|
color: #00d4ff;
|
|
}
|
|
.signal-guess-tags {
|
|
display: flex;
|
|
gap: 4px;
|
|
margin-top: 6px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.signal-guess-tag {
|
|
background: #2a2a2a;
|
|
color: #888;
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
font-size: 10px;
|
|
}
|
|
.signal-guess-alternatives {
|
|
margin-top: 8px;
|
|
}
|
|
.signal-guess-alt-toggle {
|
|
background: transparent;
|
|
border: none;
|
|
color: #666;
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 0;
|
|
}
|
|
.signal-guess-alt-toggle:hover {
|
|
color: #888;
|
|
}
|
|
.signal-guess-alt-toggle svg {
|
|
transition: transform 0.15s ease;
|
|
}
|
|
.signal-guess-alt-toggle.open svg {
|
|
transform: rotate(180deg);
|
|
}
|
|
.signal-guess-alt-list {
|
|
margin-top: 6px;
|
|
padding-left: 12px;
|
|
border-left: 2px solid #333;
|
|
}
|
|
.signal-guess-alt-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 4px 0;
|
|
font-size: 12px;
|
|
color: #999;
|
|
}
|
|
.signal-guess-alt-label {
|
|
flex: 1;
|
|
}
|
|
/* Compact badge */
|
|
.signal-guess-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
background: #1a1a1a;
|
|
border: 1px solid #333;
|
|
border-radius: 4px;
|
|
padding: 2px 6px;
|
|
font-size: 11px;
|
|
}
|
|
.signal-guess-badge-label {
|
|
color: #ccc;
|
|
}
|
|
.signal-guess-badge-conf {
|
|
padding: 1px 4px;
|
|
border-radius: 2px;
|
|
font-size: 9px;
|
|
font-weight: 600;
|
|
color: #fff;
|
|
}
|
|
/* Popup */
|
|
.signal-guess-popup {
|
|
position: fixed;
|
|
z-index: 10000;
|
|
background: #1e1e1e;
|
|
border: 1px solid #444;
|
|
border-radius: 6px;
|
|
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
|
width: 300px;
|
|
max-width: 90vw;
|
|
}
|
|
.signal-guess-popup-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 10px 12px;
|
|
border-bottom: 1px solid #333;
|
|
color: #e0e0e0;
|
|
}
|
|
.signal-guess-popup-close {
|
|
background: transparent;
|
|
border: none;
|
|
color: #666;
|
|
font-size: 18px;
|
|
cursor: pointer;
|
|
line-height: 1;
|
|
}
|
|
.signal-guess-popup-close:hover {
|
|
color: #fff;
|
|
}
|
|
.signal-guess-popup-body {
|
|
padding: 12px;
|
|
}
|
|
.signal-guess-popup-primary {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 10px;
|
|
}
|
|
.signal-guess-popup-explanation {
|
|
color: #aaa;
|
|
font-size: 12px;
|
|
line-height: 1.5;
|
|
margin: 0 0 10px 0;
|
|
}
|
|
.signal-guess-popup-tags {
|
|
display: flex;
|
|
gap: 4px;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 10px;
|
|
}
|
|
.signal-guess-popup-alts {
|
|
border-top: 1px solid #333;
|
|
padding-top: 10px;
|
|
margin-top: 10px;
|
|
}
|
|
.signal-guess-popup-alts-title {
|
|
font-size: 11px;
|
|
color: #666;
|
|
margin-bottom: 6px;
|
|
}
|
|
.signal-guess-popup-disclaimer {
|
|
font-size: 10px;
|
|
color: #555;
|
|
margin-top: 12px;
|
|
padding-top: 10px;
|
|
border-top: 1px solid #333;
|
|
line-height: 1.4;
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
// Inject styles on load
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', injectStyles);
|
|
} else {
|
|
injectStyles();
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Public API
|
|
// ==========================================================================
|
|
|
|
return {
|
|
// Constants
|
|
Confidence,
|
|
CONFIDENCE_COLORS,
|
|
SIGNAL_TYPES,
|
|
|
|
// Core function
|
|
guessSignalType,
|
|
|
|
// UI components
|
|
createGuessElement,
|
|
createCompactBadge,
|
|
showExplanationPopup,
|
|
|
|
// Utilities
|
|
escapeHtml,
|
|
injectStyles
|
|
};
|
|
|
|
})();
|
|
|
|
// Global export
|
|
window.SignalGuess = SignalGuess;
|