// Shared helpers used by the cluster table and the band map — keeps the // mode-inference logic and the status-cache key in one place so both // surfaces always read the same data. export function cleanSpotter(s: string): string { if (!s) return ''; const i = s.indexOf('-'); return i > 0 ? s.slice(0, i) : s; } // inferSpotMode picks an ADIF mode for a cluster spot. Comment text is // the strongest hint (skimmers say "FT8", "CW 24 WPM", "RTTY"); when it // fails we fall back to the IARU R1 band-plan segment for the frequency. // Returns '' when nothing matches — caller should leave the rig mode // alone instead of guessing wrong. export function inferSpotMode(comment: string, freqHz: number): string { const c = (comment || '').toUpperCase(); if (/\bFT8\b/.test(c)) return 'FT8'; if (/\bFT4\b/.test(c)) return 'FT4'; if (/\bJS8\b/.test(c)) return 'JS8'; if (/\bQ65\b/.test(c)) return 'Q65'; if (/\bMSK144\b/.test(c)) return 'MSK144'; if (/\bJT65\b/.test(c)) return 'JT65'; if (/\bJT9\b/.test(c)) return 'JT9'; if (/\bRTTY\b/.test(c)) return 'RTTY'; if (/\bPSK(63|125|250|500)\b/.test(c)) return RegExp.$1 ? `PSK${RegExp.$1}` : 'PSK31'; if (/\bPSK31?\b/.test(c)) return 'PSK31'; if (/\bOLIVIA\b/.test(c)) return 'OLIVIA'; if (/\bMFSK\b/.test(c)) return 'MFSK16'; if (/\bCW\b/.test(c) || /\bWPM\b/.test(c)) return 'CW'; if (/\bFM\b/.test(c)) return 'FM'; if (/\bAM\b/.test(c)) return 'AM'; if (/\b(SSB|USB|LSB)\b/.test(c)) return 'SSB'; const mhz = freqHz / 1_000_000; type Seg = [number, number, string]; const segs: Seg[] = [ [1.8, 1.838, 'CW'], [1.838, 1.84, 'FT8'], [1.84, 2.0, 'SSB'], [3.5, 3.58, 'CW'], [3.573, 3.576, 'FT8'], [3.58, 3.6, 'DATA'], [3.6, 4.0, 'SSB'], [5.3, 5.5, 'SSB'], [7.0, 7.04, 'CW'], [7.074, 7.077, 'FT8'], [7.0475, 7.0485, 'FT4'], [7.04, 7.1, 'DATA'], [7.1, 7.3, 'SSB'], [10.1, 10.13, 'CW'], [10.13, 10.15, 'DATA'], [14.0, 14.07, 'CW'], [14.074, 14.077, 'FT8'], [14.08, 14.0815, 'FT4'], [14.07, 14.1, 'DATA'], [14.1, 14.35, 'SSB'], [18.068, 18.095, 'CW'], [18.1, 18.103, 'FT8'], [18.095, 18.11, 'DATA'], [18.11, 18.168, 'SSB'], [21.0, 21.07, 'CW'], [21.074, 21.077, 'FT8'], [21.14, 21.143, 'FT4'], [21.07, 21.15, 'DATA'], [21.15, 21.45, 'SSB'], [24.89, 24.915, 'CW'], [24.915, 24.917, 'FT8'], [24.915, 24.94, 'DATA'], [24.94, 24.99, 'SSB'], [28.0, 28.07, 'CW'], [28.074, 28.077, 'FT8'], [28.18, 28.183, 'FT4'], [28.07, 28.3, 'DATA'], [28.3, 29.7, 'SSB'], [50.0, 50.1, 'CW'], [50.313, 50.316, 'FT8'], [50.318, 50.321, 'FT4'], [50.1, 50.5, 'SSB'], [144.0, 144.15, 'CW'], [144.174, 144.177, 'FT8'], [144.15, 144.5, 'SSB'], ]; for (const [lo, hi, m] of segs) { if (mhz >= lo && mhz < hi) return m; } return ''; } // spotModeCategory buckets the fine-grained mode from inferSpotMode into // the three families the cluster filter exposes: 'SSB' (phone: SSB/FM/AM), // 'CW', and 'DATA' (every digital mode). Returns '' when the mode is // unknown so callers can decide how to treat un-categorisable spots. export function spotModeCategory(mode: string): 'SSB' | 'CW' | 'DATA' | '' { const m = (mode || '').toUpperCase(); if (m === '') return ''; if (m === 'CW') return 'CW'; if (m === 'SSB' || m === 'USB' || m === 'LSB' || m === 'FM' || m === 'AM') return 'SSB'; // Everything else inferSpotMode can return (FT8/FT4/JS8/RTTY/PSK*/…/DATA) // is a digital mode. return 'DATA'; } // spotStatusKey is the cache key for ClusterSpotStatuses results. Must be // computed identically in the fetcher and every reader — including the // band map and the spot table — so a CW spot's status doesn't get looked // up under an empty-mode key (which always misses → false "new-slot"). export function spotStatusKey(call: string, band: string, comment: string, freqHz: number): string { return `${call}|${band}|${inferSpotMode(comment, freqHz)}`; }