diff --git a/signal-cards-mockup.html b/signal-cards-mockup.html new file mode 100644 index 0000000..9bd926f --- /dev/null +++ b/signal-cards-mockup.html @@ -0,0 +1,1490 @@ + + + + + + Signal Intelligence Feed + + + + + + +
+
+
+

Signal Feed

+
+ + Monitoring +
+
+
+
6 signals tracked
+
3 need review
+
1 new this hour
+
+
+ + +
+ Filter + + + + + + +
+ + + +
+ +
+ + +
+
+
+ APRS + 144.390 MHz +
+ + + Emergency + +
+
+
+ KD7ABC-9 + Distress + 2 min ago +
+
+ EMERGENCY - Vehicle disabled on Highway 26 MP 42. Request assistance. 2 persons, no injuries. +
+
+
+ 45.3917°N, 122.5667°W +
+
+ +
+
+
+
+
Signal Details
+
+
+ Frequency + 144.390 MHz +
+
+ RSSI + -58 dBm +
+
+ Baud Rate + 1200 bps +
+
+ Timestamp + 14:32:07 UTC +
+
+
+
+
Raw Packet
+
KD7ABC-9>APRS,WIDE1-1,WIDE2-2:!EMERGENCY! @142307h4523.50N/12234.00W_Vehicle disabled Highway 26 MP 42
+
+
+ + + +
+
+
+
+
+ + +
+
+
+ Unknown 433 MHz +
+ + + New + +
+
+
+
+ + + + + +
+ First detected 3 min ago +
+
+ + + + + Short burst, single transmission +
+
+ +
+
+
+
+
Signal Details
+
+
+ Frequency + 433.920 MHz +
+
+ RSSI + -42 dBm +
+
+ Modulation + ASK/OOK +
+
+ Duration + 1.2 sec +
+
+ Bandwidth + ~200 kHz +
+
+ Protocol + Unknown +
+
+
+
+ + + +
+
+
+
+
+ + +
+
+
+ Wi-Fi 2.4 GHz +
+ + + Burst + +
+
+
+
+ + + + + +
+ Seen 847 times today • Spike at 14:32 +
+
+ + + + Sudden activity spike, 12x normal rate +
+
+ +
+
+
+
+
Signal Details
+
+
+ Frequency + 2.437 GHz (Ch 6) +
+
+ RSSI + -34 dBm +
+
+ Baseline Avg + 71 / hour +
+
+ Current Rate + 847 / hour +
+
+
+
+
+
Activity (last 24h)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+ Pager POCSAG + 152.480 MHz +
+ + + Repeated + +
+
+
+ CAPCODE: 1234567 + Text Page + 8 min ago +
+
+ METRO DISPATCH: Unit 42 respond to 1850 NW Marshall. Medical assist, patient conscious and breathing... +
+
+ +
+
+
+
+
Signal Details
+
+
+ Frequency + 152.480 MHz +
+
+ RSSI + -67 dBm +
+
+ Baud Rate + 1200 bps +
+
+ Function + Alpha +
+
+
+
+
Full Message
+
METRO DISPATCH: Unit 42 respond to 1850 NW Marshall. Medical assist, patient conscious and breathing. Code 2.
+
+
+ + +
+
+
+
+
+ + +
+
+
+ Bluetooth 2.4 GHz +
+ + + Baseline + +
+
+
+
+ + + + + +
+ Seen 1,204 times today +
+
+ + + + + Steady background signal +
+
+ +
+
+
+
+
Signal Details
+
+
+ Frequency + 2.402-2.480 GHz +
+
+ RSSI + -72 dBm +
+
+ First Seen + 3 days ago +
+
+ Classification + Consumer BT +
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+ APRS + 144.390 MHz +
+ + + Baseline + +
+
+
+ WX7PDX-13 + Weather + 12 min ago +
+
+ Wind 8mph NW, Temp 42°F, Humidity 78%, Barometer 30.12in rising +
+
+ +
+
+
+
+
Signal Details
+
+
+ Frequency + 144.390 MHz +
+
+ RSSI + -61 dBm +
+
+ Station Type + Weather +
+
+ Report Interval + 10 min +
+
+
+
+
Raw Packet
+
WX7PDX-13>APRS,WIDE1-1:_01201432c090s008g015t042r000p000P000h78b10212
+
+
+ + +
+
+
+
+
+ +
+ + + +
+ + +
+ + + + diff --git a/static/css/components/signal-cards.css b/static/css/components/signal-cards.css new file mode 100644 index 0000000..5873680 --- /dev/null +++ b/static/css/components/signal-cards.css @@ -0,0 +1,853 @@ +/** + * Signal Cards Component System + * Reusable card components for displaying RF signals and decoded messages + * Used across: Pager, APRS, Sensors, and other signal-based modes + */ + +/* ============================================ + STATUS COLORS & VARIABLES + ============================================ */ +:root { + /* Signal status colors */ + --signal-new: #3b82f6; + --signal-new-bg: rgba(59, 130, 246, 0.12); + --signal-baseline: #6b7280; + --signal-baseline-bg: rgba(107, 114, 128, 0.08); + --signal-burst: #f59e0b; + --signal-burst-bg: rgba(245, 158, 11, 0.12); + --signal-repeated: #eab308; + --signal-repeated-bg: rgba(234, 179, 8, 0.10); + --signal-emergency: #ef4444; + --signal-emergency-bg: rgba(239, 68, 68, 0.15); + + /* Protocol colors */ + --proto-pocsag: var(--accent-cyan, #4a9eff); + --proto-flex: var(--accent-amber, #f59e0b); + --proto-aprs: #06b6d4; + --proto-ais: #8b5cf6; + --proto-acars: #ec4899; +} + +/* ============================================ + SIGNAL FEED CONTAINER + ============================================ */ +.signal-feed { + display: flex; + flex-direction: column; + gap: 8px; +} + +.signal-feed-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 0; + border-bottom: 1px solid var(--border-color); + margin-bottom: 8px; +} + +.signal-feed-title { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-secondary); +} + +.signal-feed-live { + display: flex; + align-items: center; + gap: 8px; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + color: var(--accent-green); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.signal-feed-live .live-dot { + width: 8px; + height: 8px; + background: var(--accent-green); + border-radius: 50%; + animation: signalPulse 2s ease-in-out infinite; +} + +@keyframes signalPulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(0.85); } +} + +/* ============================================ + FILTER BAR + ============================================ */ +.signal-filter-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.signal-filter-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dim); + margin-right: 6px; +} + +.signal-filter-btn { + display: inline-flex; + align-items: center; + gap: 5px; + font-family: 'Inter', sans-serif; + font-size: 11px; + font-weight: 500; + padding: 5px 10px; + border-radius: 4px; + border: 1px solid var(--border-color); + background: var(--bg-card); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s ease; +} + +.signal-filter-btn:hover { + border-color: var(--border-light); + background: var(--bg-elevated); +} + +.signal-filter-btn.active { + border-color: var(--accent-cyan); + background: var(--accent-cyan-dim); + color: var(--accent-cyan); +} + +.signal-filter-btn .filter-dot { + width: 6px; + height: 6px; + border-radius: 50%; +} + +.signal-filter-btn[data-filter="all"] .filter-dot { background: var(--text-secondary); } +.signal-filter-btn[data-filter="emergency"] .filter-dot { background: var(--signal-emergency); } +.signal-filter-btn[data-filter="new"] .filter-dot { background: var(--signal-new); } +.signal-filter-btn[data-filter="burst"] .filter-dot { background: var(--signal-burst); } +.signal-filter-btn[data-filter="repeated"] .filter-dot { background: var(--signal-repeated); } +.signal-filter-btn[data-filter="baseline"] .filter-dot { background: var(--signal-baseline); } + +.signal-filter-count { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + background: var(--bg-secondary); + padding: 1px 5px; + border-radius: 3px; + color: var(--text-dim); +} + +.signal-filter-btn.active .signal-filter-count { + background: rgba(74, 158, 255, 0.2); + color: var(--accent-cyan); +} + +.signal-filter-divider { + width: 1px; + height: 20px; + background: var(--border-color); + margin: 0 6px; +} + +/* ============================================ + BASE SIGNAL CARD + ============================================ */ +.signal-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 12px; + transition: all 0.2s ease; + position: relative; + overflow: hidden; + animation: cardSlideIn 0.25s ease; +} + +@keyframes cardSlideIn { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.signal-card:hover { + background: var(--bg-elevated); + border-color: var(--border-light); +} + +.signal-card.hidden { + display: none; +} + +/* Left accent border for status */ +.signal-card::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--card-accent, transparent); +} + +/* ============================================ + SIGNAL CARD STATUS VARIANTS + ============================================ */ +.signal-card[data-status="new"] { + --card-accent: var(--signal-new); + background: linear-gradient(90deg, var(--signal-new-bg) 0%, var(--bg-card) 35%); +} + +.signal-card[data-status="burst"] { + --card-accent: var(--signal-burst); + background: linear-gradient(90deg, var(--signal-burst-bg) 0%, var(--bg-card) 35%); +} + +.signal-card[data-status="repeated"] { + --card-accent: var(--signal-repeated); +} + +.signal-card[data-status="baseline"] { + --card-accent: var(--signal-baseline); +} + +.signal-card[data-status="emergency"] { + --card-accent: var(--signal-emergency); + background: linear-gradient(90deg, var(--signal-emergency-bg) 0%, var(--bg-card) 35%); + border-color: rgba(239, 68, 68, 0.3); +} + +/* ============================================ + CARD HEADER + ============================================ */ +.signal-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 10px; +} + +.signal-card-badges { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +/* Protocol badge */ +.signal-proto-badge { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 3px 7px; + border-radius: 3px; + border: 1px solid; +} + +.signal-proto-badge.pocsag { + background: var(--accent-cyan-dim); + color: var(--accent-cyan); + border-color: rgba(74, 158, 255, 0.25); +} + +.signal-proto-badge.flex { + background: var(--accent-amber-dim); + color: var(--accent-amber); + border-color: rgba(212, 168, 83, 0.25); +} + +.signal-proto-badge.aprs { + background: rgba(6, 182, 212, 0.15); + color: #06b6d4; + border-color: rgba(6, 182, 212, 0.25); +} + +.signal-proto-badge.ais { + background: rgba(139, 92, 246, 0.15); + color: #8b5cf6; + border-color: rgba(139, 92, 246, 0.25); +} + +.signal-proto-badge.acars { + background: rgba(236, 72, 153, 0.15); + color: #ec4899; + border-color: rgba(236, 72, 153, 0.25); +} + +/* Frequency/Address badge */ +.signal-freq-badge { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + font-weight: 500; + color: var(--text-primary); + background: var(--bg-secondary); + padding: 3px 8px; + border-radius: 3px; + border: 1px solid var(--border-color); +} + +/* Status pill */ +.signal-status-pill { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 3px 8px; + border-radius: 10px; +} + +.signal-status-pill .status-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; +} + +.signal-status-pill[data-status="new"] { + background: var(--signal-new-bg); + color: var(--signal-new); +} + +.signal-status-pill[data-status="baseline"] { + background: var(--signal-baseline-bg); + color: var(--signal-baseline); +} + +.signal-status-pill[data-status="burst"] { + background: var(--signal-burst-bg); + color: var(--signal-burst); +} + +.signal-status-pill[data-status="repeated"] { + background: var(--signal-repeated-bg); + color: var(--signal-repeated); +} + +.signal-status-pill[data-status="emergency"] { + background: var(--signal-emergency-bg); + color: var(--signal-emergency); +} + +.signal-status-pill[data-status="new"] .status-dot, +.signal-status-pill[data-status="burst"] .status-dot, +.signal-status-pill[data-status="emergency"] .status-dot { + animation: signalPulse 1.5s ease-in-out infinite; +} + +/* ============================================ + CARD BODY + ============================================ */ +.signal-card-body { + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Message metadata row */ +.signal-meta-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.signal-sender { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + color: var(--accent-green); +} + +.signal-msg-type { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-dim); + background: var(--bg-secondary); + padding: 2px 6px; + border-radius: 3px; +} + +.signal-timestamp { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--text-dim); + margin-left: auto; +} + +/* Message content preview */ +.signal-message { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + color: var(--text-primary); + background: var(--bg-secondary); + padding: 10px; + border-radius: 4px; + border-left: 2px solid var(--border-color); + line-height: 1.5; + word-break: break-word; +} + +.signal-message.numeric { + font-size: 14px; + letter-spacing: 1.5px; +} + +.signal-message.emergency { + border-left-color: var(--signal-emergency); + background: var(--signal-emergency-bg); +} + +.signal-message.truncated::after { + content: '...'; + color: var(--text-dim); +} + +/* Signal strength indicator */ +.signal-strength-row { + display: flex; + align-items: center; + gap: 12px; +} + +.signal-strength-bars { + display: flex; + align-items: flex-end; + gap: 2px; + height: 16px; +} + +.signal-strength-bars .bar { + width: 3px; + background: var(--border-light); + border-radius: 1px; + transition: background 0.2s; +} + +.signal-strength-bars .bar:nth-child(1) { height: 5px; } +.signal-strength-bars .bar:nth-child(2) { height: 8px; } +.signal-strength-bars .bar:nth-child(3) { height: 11px; } +.signal-strength-bars .bar:nth-child(4) { height: 14px; } +.signal-strength-bars .bar:nth-child(5) { height: 16px; } + +.signal-strength-bars .bar.active { + background: var(--accent-green); +} + +.signal-strength-bars .bar.active.weak { + background: var(--accent-orange); +} + +.signal-activity { + font-size: 12px; + color: var(--text-secondary); +} + +/* Behavior tag */ +.signal-behavior { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--text-dim); + background: var(--bg-secondary); + padding: 5px 8px; + border-radius: 4px; + width: fit-content; +} + +.signal-behavior svg { + width: 12px; + height: 12px; + opacity: 0.7; +} + +/* ============================================ + CARD FOOTER & ACTIONS + ============================================ */ +.signal-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--border-color); +} + +.signal-advanced-toggle { + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--text-dim); + background: none; + border: none; + cursor: pointer; + padding: 4px 6px; + margin: -4px -6px; + border-radius: 4px; + transition: all 0.15s; +} + +.signal-advanced-toggle:hover { + color: var(--text-secondary); + background: var(--bg-secondary); +} + +.signal-advanced-toggle svg { + width: 12px; + height: 12px; + transition: transform 0.2s; +} + +.signal-advanced-toggle.open svg { + transform: rotate(180deg); +} + +.signal-advanced-toggle.open { + color: var(--accent-cyan); +} + +.signal-card-actions { + display: flex; + gap: 6px; +} + +.signal-action-btn { + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-dim); + background: var(--bg-secondary); + border: 1px solid var(--border-color); + padding: 5px 8px; + border-radius: 4px; + cursor: pointer; + transition: all 0.15s; +} + +.signal-action-btn:hover { + color: var(--text-secondary); + border-color: var(--border-light); +} + +.signal-action-btn.primary { + background: var(--accent-cyan-dim); + border-color: rgba(74, 158, 255, 0.25); + color: var(--accent-cyan); +} + +.signal-action-btn.primary:hover { + background: rgba(74, 158, 255, 0.2); +} + +.signal-action-btn.danger { + background: var(--accent-red-dim); + border-color: rgba(239, 68, 68, 0.25); + color: var(--accent-red); +} + +.signal-action-btn.danger:hover { + background: rgba(239, 68, 68, 0.2); +} + +/* ============================================ + ADVANCED PANEL + ============================================ */ +.signal-advanced-panel { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.2s ease; + margin-top: 0; +} + +.signal-advanced-panel.open { + grid-template-rows: 1fr; + margin-top: 10px; +} + +.signal-advanced-inner { + overflow: hidden; +} + +.signal-advanced-content { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 10px; +} + +.signal-advanced-section { + margin-bottom: 10px; +} + +.signal-advanced-section:last-child { + margin-bottom: 0; +} + +.signal-advanced-title { + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-dim); + margin-bottom: 6px; +} + +.signal-advanced-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 6px; +} + +.signal-advanced-item { + display: flex; + flex-direction: column; + gap: 1px; +} + +.signal-advanced-label { + font-size: 9px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.signal-advanced-value { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + color: var(--text-primary); +} + +.signal-raw-data { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--text-secondary); + background: var(--bg-primary); + padding: 8px; + border-radius: 3px; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; + line-height: 1.5; +} + +.signal-advanced-actions { + display: flex; + gap: 6px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--border-color); +} + +/* ============================================ + MINI MAP (for APRS/position data) + ============================================ */ +.signal-mini-map { + width: 100%; + height: 70px; + background: var(--bg-secondary); + border-radius: 4px; + border: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; + cursor: pointer; + transition: border-color 0.15s; +} + +.signal-mini-map:hover { + border-color: var(--accent-cyan); +} + +.signal-mini-map::before { + content: ''; + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(74, 158, 255, 0.08) 1px, transparent 1px), + linear-gradient(90deg, rgba(74, 158, 255, 0.08) 1px, transparent 1px); + background-size: 14px 14px; +} + +.signal-map-pin { + width: 10px; + height: 10px; + background: var(--signal-emergency); + border-radius: 50%; + border: 2px solid var(--bg-card); + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.3); + animation: mapPing 1.5s ease-out infinite; + z-index: 1; +} + +@keyframes mapPing { + 0% { box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.4); } + 100% { box-shadow: 0 0 0 12px rgba(239, 68, 68, 0); } +} + +.signal-map-coords { + position: absolute; + bottom: 4px; + right: 6px; + font-family: 'JetBrains Mono', monospace; + font-size: 9px; + color: var(--text-dim); + background: var(--bg-card); + padding: 2px 5px; + border-radius: 2px; +} + +/* ============================================ + SPARKLINE CHART + ============================================ */ +.signal-sparkline-container { + margin-top: 6px; +} + +.signal-sparkline-label { + font-size: 9px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 3px; +} + +.signal-sparkline { + display: flex; + align-items: flex-end; + gap: 1px; + height: 28px; + padding: 3px; + background: var(--bg-primary); + border-radius: 3px; +} + +.signal-sparkline .spark-bar { + flex: 1; + background: var(--accent-cyan); + border-radius: 1px; + opacity: 0.6; + transition: opacity 0.15s; +} + +.signal-sparkline .spark-bar:hover { + opacity: 1; +} + +.signal-sparkline .spark-bar.spike { + background: var(--signal-burst); + opacity: 0.9; +} + +/* ============================================ + TOAST NOTIFICATION + ============================================ */ +.signal-toast { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%) translateY(80px); + background: var(--bg-card); + border: 1px solid var(--border-light); + padding: 10px 18px; + border-radius: 6px; + font-size: 12px; + color: var(--text-primary); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4); + z-index: 10000; + opacity: 0; + transition: all 0.25s ease; +} + +.signal-toast.show { + transform: translateX(-50%) translateY(0); + opacity: 1; +} + +.signal-toast.success { + border-color: var(--accent-green); + background: linear-gradient(90deg, var(--accent-green-dim) 0%, var(--bg-card) 40%); +} + +.signal-toast.error { + border-color: var(--accent-red); + background: linear-gradient(90deg, var(--accent-red-dim) 0%, var(--bg-card) 40%); +} + +/* ============================================ + EMPTY STATE + ============================================ */ +.signal-empty-state { + text-align: center; + padding: 40px 20px; + color: var(--text-dim); +} + +.signal-empty-state svg { + width: 40px; + height: 40px; + margin-bottom: 12px; + opacity: 0.5; +} + +.signal-empty-state p { + font-size: 13px; +} + +/* ============================================ + COMPACT MODE (for high-density display) + ============================================ */ +.signal-feed.compact .signal-card { + padding: 8px 10px; +} + +.signal-feed.compact .signal-card-header { + margin-bottom: 6px; +} + +.signal-feed.compact .signal-proto-badge { + font-size: 9px; + padding: 2px 5px; +} + +.signal-feed.compact .signal-freq-badge { + font-size: 10px; + padding: 2px 6px; +} + +.signal-feed.compact .signal-message { + font-size: 11px; + padding: 8px; +} + +.signal-feed.compact .signal-card-footer { + margin-top: 8px; + padding-top: 8px; +} diff --git a/static/js/components/signal-cards.js b/static/js/components/signal-cards.js new file mode 100644 index 0000000..107d5fe --- /dev/null +++ b/static/js/components/signal-cards.js @@ -0,0 +1,418 @@ +/** + * 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'; + + // Store for managing cards and state + const state = { + cards: new Map(), + filters: { + status: 'all', + type: 'all' + }, + 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(); + } + + /** + * Determine signal status based on message data + */ + function determineStatus(msg) { + // Check for emergency indicators + if (msg.emergency || + (msg.message && /emergency|distress|mayday|sos/i.test(msg.message))) { + return 'emergency'; + } + // Check if it's a new/first-seen signal + if (msg.isNew || msg.firstSeen) { + return 'new'; + } + // Check for burst activity + if (msg.burst || msg.spike) { + return 'burst'; + } + // Check for repeated pattern + if (msg.repeated || msg.count > 5) { + return 'repeated'; + } + // Default to baseline + 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 a pager message card + */ + function createPagerCard(msg, options = {}) { + const status = options.status || determineStatus(msg); + 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 card = document.createElement('article'); + card.className = 'signal-card'; + card.dataset.status = status; + card.dataset.type = 'message'; + card.dataset.protocol = protoClass; + if (msg.address) card.dataset.address = msg.address; + + // Build card HTML + card.innerHTML = ` +
+
+ ${escapeHtml(msg.protocol)} + Addr: ${escapeHtml(msg.address)}${msg.function ? ' / F' + escapeHtml(msg.function) : ''} +
+ ${status !== 'baseline' ? ` + + + ${status.charAt(0).toUpperCase() + status.slice(1)} + + ` : ''} +
+
+
+ ${msg.msg_type ? `${escapeHtml(msg.msg_type)}` : ''} + ${escapeHtml(relativeTime)} +
+
${escapeHtml(msg.message || '[No content]')}
+
+ +
+
+
+
+
Signal Details
+
+
+ Protocol + ${escapeHtml(msg.protocol)} +
+
+ Address + ${escapeHtml(msg.address)} +
+ ${msg.function ? ` +
+ Function + ${escapeHtml(msg.function)} +
+ ` : ''} + ${msg.msg_type ? ` +
+ Type + ${escapeHtml(msg.msg_type)} +
+ ` : ''} +
+ Timestamp + ${escapeHtml(msg.timestamp)} +
+
+
+ ${msg.raw ? ` +
+
Raw Data
+
${escapeHtml(msg.raw)}
+
+ ` : ''} +
+
+
+ `; + + 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) { + // Store muted addresses in localStorage + 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}"]`).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 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; + + // Force reflow for animation + toast.offsetHeight; + toast.classList.add('show'); + + setTimeout(() => { + toast.classList.remove('show'); + }, 2500); + } + + /** + * Initialize filter bar + */ + function initFilterBar(container, options = {}) { + const filterBar = document.createElement('div'); + filterBar.className = 'signal-filter-bar'; + filterBar.innerHTML = ` + Filter + + ${options.showEmergency !== false ? ` + + ` : ''} + + + + `; + + // Add click handlers + filterBar.querySelectorAll('.signal-filter-btn').forEach(btn => { + btn.addEventListener('click', () => { + filterBar.querySelectorAll('.signal-filter-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + state.filters.status = btn.dataset.filter; + applyFilters(container); + }); + }); + + return filterBar; + } + + /** + * Apply current filters to cards + */ + function applyFilters(container) { + const cards = container.querySelectorAll('.signal-card'); + let visibleCount = 0; + + cards.forEach(card => { + const cardStatus = card.dataset.status; + const cardType = card.dataset.type; + + const statusMatch = state.filters.status === 'all' || cardStatus === state.filters.status; + const typeMatch = state.filters.type === 'all' || cardType === state.filters.type; + + if (statusMatch && typeMatch) { + card.classList.remove('hidden'); + visibleCount++; + } else { + card.classList.add('hidden'); + } + }); + + // Show/hide empty state + const emptyState = container.querySelector('.signal-empty-state'); + if (emptyState) { + emptyState.style.display = visibleCount === 0 ? 'block' : 'none'; + } + } + + /** + * Update filter counts + */ + function updateCounts(container) { + const cards = container.querySelectorAll('.signal-card'); + const counts = { + all: 0, + emergency: 0, + new: 0, + burst: 0, + repeated: 0, + baseline: 0 + }; + + cards.forEach(card => { + counts.all++; + const status = card.dataset.status; + if (counts.hasOwnProperty(status)) { + counts[status]++; + } + }); + + // Update count badges + Object.keys(counts).forEach(key => { + const badge = container.querySelector(`[data-count="${key}"]`); + if (badge) { + badge.textContent = counts[key]; + } + }); + + state.counts = counts; + return counts; + } + + /** + * 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 { + createPagerCard, + toggleAdvanced, + copyMessage, + muteAddress, + isAddressMuted, + showToast, + initFilterBar, + applyFilters, + updateCounts, + updateTimestamps, + escapeHtml, + formatRelativeTime, + determineStatus, + getProtoClass, + state + }; +})(); + +// Make globally available +window.SignalCards = SignalCards; diff --git a/templates/index.html b/templates/index.html index a52ad70..cf50d38 100644 --- a/templates/index.html +++ b/templates/index.html @@ -16,6 +16,7 @@ + @@ -1522,9 +1523,12 @@ -
-
- Configure settings and click "Start Decoding" to begin. +
+
+ + + +

Configure settings and click "Start Decoding" to begin.

@@ -1579,6 +1583,7 @@ +