diff --git a/static/js/components/signal-id-modal.js b/static/js/components/signal-id-modal.js
new file mode 100644
index 0000000..f680272
--- /dev/null
+++ b/static/js/components/signal-id-modal.js
@@ -0,0 +1,295 @@
+/* Signal identification modal — standalone IIFE component.
+ * Usage: SignalIdModal.open({ frequency_mhz: 98.5, modulation: 'WFM' })
+ * SignalIdModal.open({}) // blank fields
+ * SignalIdModal.close()
+ */
+window.SignalIdModal = (function () {
+ 'use strict';
+
+ var _modal = null;
+ var _backdrop = null;
+ var _lastFreq = null;
+ var _lastMod = null;
+
+ function _esc(s) {
+ return String(s == null ? '' : s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ function _safeSigIdUrl(url) {
+ try {
+ var p = new URL(String(url || ''));
+ if (p.protocol === 'https:' && p.hostname.endsWith('sigidwiki.com')) return p.toString();
+ } catch (_) {}
+ return null;
+ }
+
+ function _build() {
+ if (_modal) return;
+
+ _backdrop = document.createElement('div');
+ _backdrop.id = 'sigIdBackdrop';
+ _backdrop.style.cssText = [
+ 'position:fixed', 'inset:0', 'z-index:9998',
+ 'background:rgba(0,0,0,0.65)', 'display:none',
+ ].join(';');
+ _backdrop.addEventListener('click', close);
+
+ _modal = document.createElement('div');
+ _modal.id = 'sigIdModal';
+ _modal.style.cssText = [
+ 'position:fixed', 'top:50%', 'left:50%',
+ 'transform:translate(-50%,-50%)',
+ 'z-index:9999',
+ 'width:min(500px,95vw)', 'max-height:85vh',
+ 'overflow-y:auto',
+ 'background:var(--bg-card,#1a1a2e)',
+ 'border:1px solid rgba(255,255,255,0.12)',
+ 'border-radius:8px', 'padding:16px',
+ 'display:none',
+ 'font-family:var(--font-mono,monospace)',
+ 'color:var(--text-primary,#e0e0e0)',
+ 'box-sizing:border-box',
+ ].join(';');
+
+ _modal.innerHTML = [
+ '
',
+ '
Signal Identification
',
+ '
',
+ '
',
+ '',
+ '
',
+ ' ',
+ ' ',
+ '
',
+ '
',
+ ' ',
+ ' ',
+ '
',
+ '
',
+ '',
+ '
',
+ ' ',
+ ' ',
+ '
',
+ '
',
+ '
',
+ '',
+ '',
+ ].join('');
+
+ document.body.appendChild(_backdrop);
+ document.body.appendChild(_modal);
+
+ document.getElementById('sigIdClose').addEventListener('click', close);
+ document.getElementById('sigIdSearch').addEventListener('click', search);
+ document.getElementById('sigIdFreq').addEventListener('input', _validateFreq);
+ }
+
+ function _validateFreq() {
+ var freq = document.getElementById('sigIdFreq');
+ var btn = document.getElementById('sigIdSearch');
+ if (!freq || !btn) return;
+ var val = parseFloat(freq.value);
+ var valid = isFinite(val) && val > 0;
+ btn.disabled = !valid;
+ freq.style.borderColor = (freq.value === '' || valid)
+ ? 'rgba(255,255,255,0.15)'
+ : 'var(--accent-red,#ff4444)';
+ }
+
+ function _setStatus(text, isError) {
+ var el = document.getElementById('sigIdStatus');
+ if (!el) return;
+ el.textContent = text || '';
+ el.style.color = isError ? 'var(--accent-red,#ff4444)' : 'var(--text-muted,#888)';
+ }
+
+ function _renderResults(matches, freqMhz) {
+ var el = document.getElementById('sigIdResults');
+ if (!el) return;
+ if (!matches.length) {
+ el.innerHTML = ''
+ + 'No signals match ' + freqMhz.toFixed(4) + ' MHz'
+ + ' — try adjusting the frequency or leaving bandwidth blank.
';
+ return;
+ }
+ el.innerHTML = matches.map(function (m, i) {
+ var dot = i === 0 ? '●' : '○';
+ var score = Number(m.score) || 0;
+ var name = _esc(m.name || 'Unknown');
+ var desc = _esc(m.description || '');
+ var cats = Array.isArray(m.categories) ? m.categories : [];
+ var reasons = Array.isArray(m.match_reasons) ? m.match_reasons : [];
+ var safeUrl = _safeSigIdUrl(m.sigidwiki_url);
+ var ranges = Array.isArray(m.frequency_ranges) ? m.frequency_ranges : [];
+ var mods = Array.isArray(m.modulations) ? m.modulations : [];
+ var bw = m.bandwidth_range;
+
+ var freqStr = ranges.length
+ ? (ranges[0].min_hz / 1e6).toFixed(3) + '–' + (ranges[0].max_hz / 1e6).toFixed(3) + ' MHz'
+ : '';
+ var bwStr = bw
+ ? (bw.min_hz >= 1000 ? (bw.min_hz / 1000).toFixed(0) + 'k' : bw.min_hz)
+ + '–'
+ + (bw.max_hz >= 1000 ? (bw.max_hz / 1000).toFixed(0) + 'k' : bw.max_hz)
+ + ' Hz'
+ : '';
+ var modStr = mods.join(', ');
+ var meta = [freqStr, modStr, bwStr].filter(Boolean).join(' · ');
+
+ var catHtml = cats.slice(0, 5).map(function (c) {
+ return ''
+ + _esc(c) + '';
+ }).join('');
+
+ return ''
+ + '
'
+ + '
' + dot + ' ' + name + '
'
+ + '
'
+ + '
' + score + '
'
+ + '
'
+ + (meta ? '
' + _esc(meta) + '
' : '')
+ + (desc ? '
' + desc + '
' : '')
+ + (catHtml ? '
' + catHtml + '
' : '')
+ + (reasons.length ? '
'
+ + reasons.map(_esc).join(' · ') + '
' : '')
+ + (safeUrl ? '
' : '')
+ + '
';
+ }).join('');
+ }
+
+ function open(opts) {
+ _build();
+ opts = opts || {};
+
+ var freqEl = document.getElementById('sigIdFreq');
+ var modEl = document.getElementById('sigIdMod');
+ var bwEl = document.getElementById('sigIdBw');
+ var resultsEl = document.getElementById('sigIdResults');
+
+ if (opts.frequency_mhz != null) {
+ freqEl.value = Number(opts.frequency_mhz).toFixed(4);
+ _lastFreq = Number(opts.frequency_mhz);
+ } else {
+ freqEl.value = '';
+ _lastFreq = null;
+ }
+
+ var modVal = (opts.modulation || '').toUpperCase();
+ modEl.value = modVal || '';
+
+ bwEl.value = '';
+ resultsEl.innerHTML = '';
+ _setStatus('');
+ _validateFreq();
+
+ _backdrop.style.display = 'block';
+ _modal.style.display = 'block';
+
+ if (!opts.frequency_mhz) {
+ setTimeout(function () { freqEl.focus(); }, 50);
+ }
+ }
+
+ function close() {
+ if (!_modal) return;
+ _modal.style.display = 'none';
+ _backdrop.style.display = 'none';
+ }
+
+ function search() {
+ var freqEl = document.getElementById('sigIdFreq');
+ var modEl = document.getElementById('sigIdMod');
+ var bwEl = document.getElementById('sigIdBw');
+ var searchBtn = document.getElementById('sigIdSearch');
+
+ var freq = parseFloat(freqEl.value);
+ if (!isFinite(freq) || freq <= 0) return;
+
+ var bwKhz = parseFloat(bwEl.value);
+ var bwHz = (isFinite(bwKhz) && bwKhz > 0) ? Math.round(bwKhz * 1000) : null;
+ var mod = (modEl.value || '').trim().toUpperCase() || null;
+
+ _setStatus('Searching ' + freq.toFixed(4) + ' MHz…');
+ document.getElementById('sigIdResults').innerHTML = '';
+ searchBtn.disabled = true;
+
+ fetch('/signalid/match', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({
+ frequency_mhz: freq,
+ bandwidth_hz: bwHz,
+ modulation: mod,
+ limit: 8,
+ }),
+ })
+ .then(function (r) { return r.json(); })
+ .then(function (data) {
+ searchBtn.disabled = false;
+ if (!data || data.status !== 'ok') {
+ _setStatus('Search failed', true);
+ return;
+ }
+ var matches = data.matches || [];
+ if (matches.length) {
+ _setStatus(matches.length + ' match' + (matches.length !== 1 ? 'es' : '')
+ + ' for ' + freq.toFixed(4) + ' MHz');
+ }
+ _renderResults(matches, freq);
+ })
+ .catch(function () {
+ searchBtn.disabled = false;
+ _setStatus('Search failed — check connection', true);
+ document.getElementById('sigIdResults').innerHTML =
+ ''
+ + 'Network error —
';
+ });
+ }
+
+ return {open: open, close: close, search: search};
+})();
diff --git a/templates/index.html b/templates/index.html
index 0239041..75252b6 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -3678,6 +3678,7 @@
+