diff --git a/static/css/components/signal-cards.css b/static/css/components/signal-cards.css index 5873680..c411529 100644 --- a/static/css/components/signal-cards.css +++ b/static/css/components/signal-cards.css @@ -851,3 +851,288 @@ margin-top: 8px; padding-top: 8px; } + +/* ============================================ + SEARCH INPUT + ============================================ */ +.signal-search-container { + flex: 1; + min-width: 150px; + max-width: 250px; +} + +.signal-search-input { + width: 100%; + padding: 6px 10px; + font-family: 'Inter', sans-serif; + font-size: 11px; + color: var(--text-primary); + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 4px; + outline: none; + transition: all 0.15s; +} + +.signal-search-input::placeholder { + color: var(--text-dim); +} + +.signal-search-input:focus { + border-color: var(--accent-cyan); + background: var(--bg-elevated); +} + +/* ============================================ + SEEN COUNT BADGE + ============================================ */ +.signal-seen-count { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--text-dim); + background: var(--bg-secondary); + padding: 2px 5px; + border-radius: 3px; +} + +/* ============================================ + SENSOR DATA DISPLAY + ============================================ */ +.signal-sensor-data { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 10px; + background: var(--bg-secondary); + border-radius: 4px; + border-left: 2px solid var(--accent-cyan); +} + +.signal-sensor-reading { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 70px; +} + +.signal-sensor-reading .sensor-label { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-dim); +} + +.signal-sensor-reading .sensor-value { + font-family: 'JetBrains Mono', monospace; + font-size: 14px; + font-weight: 500; + color: var(--text-primary); +} + +.signal-sensor-reading .sensor-value.low-battery { + color: var(--accent-red); +} + +/* Sensor protocol badge */ +.signal-proto-badge.sensor { + background: var(--accent-green-dim); + color: var(--accent-green); + border-color: rgba(34, 197, 94, 0.25); +} + +/* ============================================ + METER DATA DISPLAY + ============================================ */ +.signal-meter-data { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding: 12px; + background: var(--bg-secondary); + border-radius: 4px; + border-left: 2px solid var(--accent-yellow); +} + +.signal-meter-reading { + display: flex; + flex-direction: column; + gap: 3px; +} + +.signal-meter-reading .meter-label { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-dim); +} + +.signal-meter-reading .meter-value { + font-family: 'JetBrains Mono', monospace; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); +} + +/* Meter protocol badges */ +.signal-proto-badge.meter { + background: rgba(234, 179, 8, 0.15); + color: #eab308; + border-color: rgba(234, 179, 8, 0.25); +} + +.signal-proto-badge.meter.electric { + background: rgba(234, 179, 8, 0.15); + color: #eab308; + border-color: rgba(234, 179, 8, 0.25); +} + +.signal-proto-badge.meter.gas { + background: rgba(249, 115, 22, 0.15); + color: #f97316; + border-color: rgba(249, 115, 22, 0.25); +} + +.signal-proto-badge.meter.water { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; + border-color: rgba(59, 130, 246, 0.25); +} + +/* ============================================ + APRS SYMBOL + ============================================ */ +.signal-aprs-symbol { + font-size: 12px; + padding: 2px 4px; + background: var(--bg-secondary); + border-radius: 3px; +} + +/* ============================================ + DISTANCE DISPLAY + ============================================ */ +.signal-distance { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--accent-green); + font-weight: 500; +} + +/* ============================================ + COMPACT CARD VARIANT + For constrained layouts like APRS station list + ============================================ */ +.signal-card-compact { + padding: 10px 12px; +} + +.signal-card-compact .signal-card-header { + margin-bottom: 6px; +} + +.signal-card-compact .signal-proto-badge { + font-size: 9px; + padding: 2px 5px; +} + +.signal-card-compact .signal-freq-badge { + font-size: 10px; + padding: 2px 6px; +} + +.signal-card-compact .signal-status-pill { + font-size: 9px; + padding: 2px 6px; +} + +.signal-card-compact .signal-message { + font-size: 11px; + padding: 6px 8px; + max-height: 40px; +} + +.signal-card-compact .signal-meta-row { + font-size: 9px; +} + +.signal-card-compact .signal-mini-map { + padding: 6px 8px; + font-size: 10px; +} + +.signal-card-compact .signal-card-footer { + margin-top: 6px; + padding-top: 6px; +} + +.signal-card-compact .signal-advanced-toggle { + font-size: 9px; + padding: 3px 6px; +} + +/* Compact filter bar for APRS */ +.signal-filter-bar-compact { + padding: 6px 8px; + margin-bottom: 8px; + gap: 4px; +} + +.signal-filter-bar-compact .signal-filter-btn { + padding: 3px 6px; + font-size: 9px; +} + +.signal-filter-bar-compact .signal-filter-count { + font-size: 8px; + padding: 1px 3px; + min-width: 14px; +} + +.signal-filter-bar-compact .signal-search-input { + padding: 4px 8px; + font-size: 10px; +} + +.signal-filter-bar-compact .signal-filter-divider { + margin: 0 4px; +} + +/* ============================================ + TONE ONLY MESSAGE STYLING + ============================================ */ +.signal-message.tone-only { + color: var(--text-dim); + font-style: italic; + border-left-color: var(--border-color); +} + +/* ============================================ + FILTER BAR RESPONSIVE + ============================================ */ +@media (max-width: 768px) { + .signal-filter-bar { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .signal-filter-bar > .signal-filter-label { + margin-top: 8px; + } + + .signal-filter-bar > .signal-filter-label:first-child { + margin-top: 0; + } + + .signal-filter-divider { + display: none; + } + + .signal-search-container { + max-width: none; + } + + .signal-filter-bar .signal-filter-btn { + flex: 1; + } +} diff --git a/static/js/components/signal-cards.js b/static/js/components/signal-cards.js index 107d5fe..499af0a 100644 --- a/static/js/components/signal-cards.js +++ b/static/js/components/signal-cards.js @@ -7,12 +7,31 @@ const SignalCards = (function() { 'use strict'; + // 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', - type: 'all' + protocol: 'all', + msgType: 'all', + search: '' }, counts: { all: 0, @@ -50,27 +69,123 @@ const SignalCards = (function() { } /** - * Determine signal status based on message data + * Track an address/identifier and return its status */ - function determineStatus(msg) { - // Check for emergency indicators + 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/i.test(msg.message))) { + (msg.message && /emergency|distress|mayday|sos|911|help/i.test(msg.message))) { return 'emergency'; } - // Check if it's a new/first-seen signal - if (msg.isNew || msg.firstSeen) { + + // 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'; } - // Check for burst activity - if (msg.burst || msg.spike) { + if (stats.isBurst) { return 'burst'; } - // Check for repeated pattern - if (msg.repeated || msg.count > 5) { + if (stats.isRepeated) { return 'repeated'; } - // Default to baseline return 'baseline'; } @@ -96,24 +211,39 @@ const SignalCards = (function() { return /^[0-9\s\-\*\#U]+$/.test(message); } + /** + * 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); + 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; - // Build card HTML + // Get address stats for display + const stats = getAddressStats('pager', msg.address); + const seenCount = stats ? stats.count : 1; + card.innerHTML = `