µWave Activity Day Planner
Microwave QSO Planning · 1.2 GHz — 76 GHz
Reference Grid
Ref Call
← Back
Stations 0
0 / 0 in matrix
📡
No stations yet.
Click + Add Station to begin.
Stations: 0
LINK BUDGET
TERRAIN
MATRIX
EXPORT
⛰️
Select a station
to view terrain profile.
📊
Add stations to see the QSO matrix.
Reference Station Summary
Export bearing, distance, link budgets for all stations from your reference grid/callsign.
All Station Data Pack
Full data for all participants — bearings, distances, link margins, comms info — one row per station pair.
Printable Activity Sheet
Opens a printer-friendly summary for your reference station.
Station Database — Server Sync
Stations are saved to the server automatically whenever you add, edit or delete a station. All participants reload to see changes instantly.

Files needed on server:
uwave-stations.json — the database
uwave-save.php — the write endpoint
ACTIVITY CHAT
0 online
µWave Activity Day Chat — messages appear here
You:
`); win.document.close(); } function dl(content,filename,type) { const blob=new Blob([content],{type}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=filename; a.click(); } // ═══════════════════════════════════════════════════════════════════ // PIN PROTECTION // ═══════════════════════════════════════════════════════════════════ // PIN verification — djb2 hash of PIN (synchronous, works on HTTP+HTTPS) // Hash: djb2('112565659' is hash of the 5-digit operator PIN) // To change PIN: python3 -c "h=5381;[h:=((h*33)^ord(c))&0xFFFFFFFF for c in 'NEWPIN'];print(h)" const _PIN_HASH = 112565659; function _hashPin(pin) { let h = 5381; for (let i = 0; i < pin.length; i++) { h = ((h * 33) ^ pin.charCodeAt(i)) >>> 0; } return h; } let _pinBuffer = ''; let _pinCallback = null; let _pinAttempts = 0; let _pinLocked = false; let _pinLockUntil= 0; function requirePin(actionLabel, callback) { if (_pinLocked && Date.now() < _pinLockUntil) { const secs = Math.ceil((_pinLockUntil - Date.now()) / 1000); alert('Too many wrong attempts. Try again in ' + secs + ' seconds.'); return; } _pinBuffer = ''; _pinCallback = callback; _pinAttempts = 0; document.getElementById('pinActionLabel').textContent = actionLabel; document.getElementById('pinErr').textContent = ''; updatePinDots(); document.getElementById('pinModal').classList.add('open'); } function pinKey(d) { if (_pinBuffer.length >= 5) return; _pinBuffer += d; updatePinDots(); if (_pinBuffer.length === 5) { setTimeout(checkPin, 120); } } function pinBackspace() { _pinBuffer = _pinBuffer.slice(0, -1); updatePinDots(); document.getElementById('pinErr').textContent = ''; document.querySelectorAll('.pin-dot').forEach(d => d.classList.remove('error')); } function updatePinDots() { for (let i = 0; i < 5; i++) { const dot = document.getElementById('pd' + i); if (!dot) continue; dot.classList.toggle('filled', i < _pinBuffer.length); dot.classList.remove('error'); } } function checkPin() { if (_hashPin(_pinBuffer) === _PIN_HASH) { _pinAttempts = 0; _pinLocked = false; closePinModal(); if (_pinCallback) _pinCallback(); _pinCallback = null; } else { _pinAttempts++; document.querySelectorAll('.pin-dot').forEach(d => { d.classList.remove('filled'); d.classList.add('error'); }); const remaining = 5 - _pinAttempts; if (_pinAttempts >= 5) { _pinLocked = true; _pinLockUntil = Date.now() + 60000; document.getElementById('pinErr').textContent = 'Too many attempts. Locked for 60s.'; setTimeout(closePinModal, 1500); } else { document.getElementById('pinErr').textContent = 'Wrong PIN — ' + remaining + ' attempt' + (remaining !== 1 ? 's' : '') + ' left.'; setTimeout(() => { _pinBuffer = ''; updatePinDots(); document.getElementById('pinErr').textContent = remaining + ' attempt' + (remaining !== 1 ? 's' : '') + ' left.'; }, 800); } } } function pinCancel() { _pinCallback = null; _pinBuffer = ''; closePinModal(); } function closePinModal() { document.getElementById('pinModal').classList.remove('open'); _pinBuffer = ''; updatePinDots(); } // Keyboard support for PIN modal document.addEventListener('keydown', e => { if (!document.getElementById('pinModal').classList.contains('open')) return; if (e.key >= '0' && e.key <= '9') pinKey(e.key); else if (e.key === 'Backspace') pinBackspace(); else if (e.key === 'Escape') pinCancel(); }); document.addEventListener('DOMContentLoaded', async () => { initMap(); requestAnimationFrame(() => { if (map) map.invalidateSize(false); }); setTimeout(() => { if (map) map.invalidateSize(false); }, 300); setTimeout(() => { if (map) map.invalidateSize(false); }, 1000); renderStationList(); buildMatrix(); startChat(); // Log this visit logVisit(); setLoadingStatus('Connecting to server…'); const serverLoaded = await fetchServerDB(false); if (!serverLoaded) { const demo=[ {id:'d1',callsign:'K6VHF',name:'Alex',grid:'DN31ux',bands:[ {mhz:10368,label:'10 GHz',alias:'3cm',power:5,antType:'Dish',dishSize:0.6,antGain:parseFloat(dishGain(0.6,10368).toFixed(1)),cableLoss:.5}, {mhz:5760,label:'5.7 GHz',alias:'6cm',power:10,antType:'Dish',dishSize:0.6,antGain:parseFloat(dishGain(0.6,5760).toFixed(1)),cableLoss:.5}, {mhz:1296,label:'1.3 GHz',alias:'23cm',power:100,antType:'Yagi',yElements:11,antGain:parseFloat(yagiGain(11).toFixed(1)),cableLoss:1}, ],comms:['Cell Phone','2m Net'],phone:'',email:'',repeater:'146.520',dmr:'',notes:''}, {id:'d2',callsign:'W6ABC',name:'Jim',grid:'DM04',bands:[ {mhz:10368,label:'10 GHz',alias:'3cm',power:3,antType:'Horn',dishSize:0.25,antGain:parseFloat(dishGain(0.25,10368,0.75).toFixed(1)),cableLoss:.3}, {mhz:1296,label:'1.3 GHz',alias:'23cm',power:50,antType:'Loop Yagi',yElements:16,antGain:parseFloat(yagiGain(16).toFixed(1)),cableLoss:1.5}, ],comms:['Cell Phone','Echolink'],phone:'',email:'',repeater:'',dmr:'',notes:''}, {id:'d3',callsign:'N7XYZ',name:'Bob',grid:'DN40',bands:[ {mhz:10368,label:'10 GHz',alias:'3cm',power:8,antType:'Dish',dishSize:0.9,antGain:parseFloat(dishGain(0.9,10368).toFixed(1)),cableLoss:.4}, {mhz:5760,label:'5.7 GHz',alias:'6cm',power:5,antType:'Dish',dishSize:0.9,antGain:parseFloat(dishGain(0.9,5760).toFixed(1)),cableLoss:.5}, {mhz:24048,label:'24 GHz',alias:'1.25cm',power:.5,antType:'Dish',dishSize:0.3,antGain:parseFloat(dishGain(0.3,24048).toFixed(1)),cableLoss:.8}, ],comms:['DMR','2m Net'],phone:'',email:'',repeater:'',dmr:'310123',notes:'Operating from 8am-6pm MDT'}, ]; stations=demo; document.getElementById('refCall').value='K6VHF'; document.getElementById('refGrid').value='DN31ux'; onRefChange(); renderStationList(); refreshMarkers(); buildMatrix(); const bounds=stations.map(s=>gridToLL(s.grid)).filter(Boolean).map(ll=>[ll.lat,ll.lon]); if(bounds.length) map.fitBounds(L.latLngBounds(bounds).pad(0.4)); setLoadingStatus('Demo data loaded','warn'); } }); document.addEventListener('DOMContentLoaded', () => { if (isMobile()) { setTimeout(syncMobStations, 1500); const orig = window.renderStationList; window.renderStationList = function() { orig(); syncMobStations(); }; const origAM = window.appendMessages; window.appendMessages = function(msgs) { origAM(msgs); const chatOpen = document.getElementById('mobChat')?.classList.contains('open'); if (chatOpen) syncMobChat(); else { const badge = document.getElementById('mnb-chat-badge'); if (badge && msgs.length) { const cur = parseInt(badge.textContent) || 0; badge.textContent = cur + msgs.length > 9 ? '9+' : cur + msgs.length; badge.classList.add('show'); } } }; const origRO = window.renderOnline; window.renderOnline = function(users) { origRO(users); syncMobOnline(users); }; document.getElementById('mnb-chat')?.addEventListener('click', () => { const badge = document.getElementById('mnb-chat-badge'); if (badge) { badge.textContent = ''; badge.classList.remove('show'); } }); } }); // ═══════════════════════════════════════════════════════════════════ // DETAIL MODAL — full-screen terrain + link budget // ═══════════════════════════════════════════════════════════════════ function openDetailModal() { const refSt = getRefStation(); if (!selectedStation || !refSt) { showToast('Select a reference callsign and click a station first.', 3000); return; } const modal = document.getElementById('detailModal'); if (!modal) { console.error('detailModal not found'); return; } modal.style.display = 'flex'; document.body.style.overflow = 'hidden'; populateModal(); // Redraw terrain at large size after layout requestAnimationFrame(() => drawModalTerrain()); setTimeout(() => drawModalTerrain(), 100); } function closeDetailModal() { (document.getElementById('detailModal')||{style:{}}).style.display = 'none'; document.body.style.overflow = ''; } // Close on Escape document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDetailModal(); }); function populateModal() { const refSt = getRefStation(); const tgt = selectedStation; if (!refSt || !tgt) return; const llA = gridToLL(refSt.grid), llB = gridToLL(tgt.grid); if (!llA || !llB) return; const dist = haversine(llA.lat, llA.lon, llB.lat, llB.lon); const brng = bearing(llA.lat, llA.lon, llB.lat, llB.lon); const cb = commonBands(refSt, tgt); // Header document.getElementById('modalPathTitle').textContent = refSt.callsign + ' → ' + tgt.callsign; document.getElementById('modalPathSub').textContent = dist.toFixed(1) + ' km | ' + bearingStr(brng) + ' | ' + (cb.length ? cb.map(b => b.label).join(', ') : 'No common bands'); // Band selector (sync with main panel) const bSel = document.getElementById('modalBandSel'); bSel.innerHTML = ''; const srcSel = document.getElementById('lbBandSel'); if (srcSel) { Array.from(srcSel.options).forEach(o => { const opt = document.createElement('option'); opt.value = o.value; opt.textContent = o.textContent; if (o.selected) opt.selected = true; bSel.appendChild(opt); }); } else { BANDS.forEach(b => { const o = document.createElement('option'); o.value = b.mhz; o.textContent = b.label; bSel.appendChild(o); }); } // Mode pills const modeSel = document.getElementById('modalModeSelect'); modeSel.innerHTML = ['SSB','CW','FT8','FT4','MSK144','QRA64'].map(m => `${m}` ).join(''); // Right panel — path info + station details const phone = decryptContact(tgt.phone || ''); const email = decryptContact(tgt.email || ''); document.getElementById('modalPathInfo').innerHTML = `
PATH
${infoRow('From', refSt.callsign + ' (' + refSt.grid + ')')} ${infoRow('To', tgt.callsign + ' (' + tgt.grid + ')')} ${infoRow('Distance', dist.toFixed(2) + ' km')} ${infoRow('Bearing', bearingStr(brng))} ${infoRow('Common Bands', cb.length ? cb.map(b=>b.label).join(', ') : 'None')}
TARGET STATION
${infoRow('Operator', tgt.name || '—')} ${infoRow('Grid', tgt.grid)} ${tgt.repeater ? infoRow('Repeater', tgt.repeater) : ''} ${tgt.dmr ? infoRow('DMR', tgt.dmr) : ''} ${(tgt.comms||[]).length ? infoRow('Comms', tgt.comms.join(', ')) : ''}
${phone || email ? `
CONTACT
${phone ? `
📞 ••••••••••
` : ''} ${email ? `
••••••••••
` : ''}
` : ''} ${tgt.notes ? `
${tgt.notes}
` : ''} `; modalCalcLinkBudget(); setTimeout(drawModalCompass, 50); } // Sync scatter toggles between main panel and modal function syncScatterToggles(src, targetId) { const target = document.getElementById(targetId); if (target) target.checked = src.checked; drawTerrain(); } function syncScatterVal(src, targetId) { const target = document.getElementById(targetId); if (target) target.value = src.value; drawTerrain(); } function modalToggleContact(maskId, valId, btn) { const mask = document.getElementById(maskId); const val = document.getElementById(valId); if (!mask || !val) return; const showing = mask.style.display === 'none'; mask.style.display = showing ? '' : 'none'; val.style.display = showing ? 'none' : ''; btn.textContent = showing ? '👁' : '🙈'; btn.style.color = showing ? 'var(--faint)' : 'var(--accent)'; } function infoRow(k, v) { return `
${k} ${v}
`; } function modalSelMode(el) { document.querySelectorAll('#modalModeSelect span').forEach(s => { s.style.color = 'var(--faint)'; s.style.borderColor = 'var(--border)'; s.style.background = 'none'; }); el.style.color = 'var(--accent)'; el.style.borderColor = 'var(--accent)'; el.style.background = 'rgba(0,200,255,.1)'; selectedMode = el.dataset.mode; // Also update main panel mode pills document.querySelectorAll('.mode-pill').forEach(p => { p.classList.toggle('sel', p.dataset.mode === selectedMode); }); modalCalcLinkBudget(); } function modalBandChange() { // Sync main band selector const v = document.getElementById('modalBandSel').value; const main = document.getElementById('lbBandSel'); if (main) main.value = v; modalCalcLinkBudget(); drawModalTerrain(); } function modalCalcLinkBudget() { const refSt = getRefStation(); const tgt = selectedStation; if (!refSt || !tgt) return; const llA = gridToLL(refSt.grid), llB = gridToLL(tgt.grid); if (!llA || !llB) return; const dist = haversine(llA.lat, llA.lon, llB.lat, llB.lon); const freqMHz = parseInt(document.getElementById('modalBandSel').value) || 1296; const bA = refSt.bands ? refSt.bands.find(b => b.mhz === freqMHz) : null; const bB = tgt.bands ? tgt.bands.find(b => b.mhz === freqMHz) : null; const txPowW = bA?.power || 1; const txGain = bA?.antGain|| 0; const txCable = bA?.cableLoss||0; const rxGain = bB?.antGain|| 0; const rxCable = bB?.cableLoss||0; const txpDbm = dBWtodBm(wTodBW(txPowW)); const eirp = txpDbm + txGain - txCable; const pathLoss= fspl(dist, freqMHz); const key = terrainCacheKey(refSt.id || 'ref', tgt.id); const tPts = terrainCache[key]?.points || terrainPoints; const tAnalysis = tPts ? analyseTerrainPath(tPts, freqMHz) : { penaltyDB:0, note:'No terrain data', losOk:true, fresnelMinPct:100 }; const rxLvl = eirp - pathLoss - tAnalysis.penaltyDB + rxGain - rxCable; const thresh = rxSensitivity(selectedMode); const margin = rxLvl - thresh; const fmt = (v, d=1) => v.toFixed(d); const mc = margin>10?'var(--ok)':margin>0?'var(--warn)':'var(--danger)'; document.getElementById('modalLB').innerHTML = `
TRANSMITTER (${refSt.callsign})
${infoRow('Tx Power', txPowW + ' W (' + fmt(txpDbm) + ' dBm)')} ${infoRow('Antenna Gain', txGain + ' dBi' + (txCable ? ' − ' + txCable + ' dB cable' : ''))} ${infoRow('EIRP', fmt(eirp) + ' dBm')} ${bA ? infoRow('Antenna Type', (bA.antType||'') + (bA.dishSize?' — '+bA.dishSize+'m ⌀':bA.yElements?' — '+bA.yElements+' el':'')) : ''}
PATH LOSSES
${infoRow('Free-Space Path Loss', '−' + fmt(pathLoss) + ' dB')} ${tAnalysis.penaltyDB > 0.1 ? infoRow('⛰ Terrain/Fresnel', '+' + fmt(tAnalysis.penaltyDB,1) + ' dB') : infoRow('⛰ Terrain', 'Clear')} ${infoRow('Total Path Loss', '−' + fmt(pathLoss + tAnalysis.penaltyDB) + ' dB')}
RECEIVER (${tgt.callsign})
${infoRow('Antenna Gain', rxGain + ' dBi' + (rxCable ? ' − ' + rxCable + ' dB cable' : ''))} ${bB ? infoRow('Antenna Type', (bB.antType||'') + (bB.dishSize?' — '+bB.dishSize+'m ⌀':bB.yElements?' — '+bB.yElements+' el':'')) : ''} ${infoRow('Rx Signal Level', '' + fmt(rxLvl) + ' dBm')} ${infoRow('Mode Threshold', fmt(thresh) + ' dBm (' + selectedMode + ')')}
${margin>=0?'+':''}${fmt(margin)} dB
LINK MARGIN · ${selectedMode}
${tAnalysis.note}
`; // Verdict bar const verdict = margin>10?'✓ QSO LIKELY':margin>0?'~ QSO POSSIBLE':'✗ QSO UNLIKELY'; const losTag = tAnalysis.losOk ? '✓ LOS CLEAR' : '⛰ OBSTRUCTED'; (document.getElementById('modalVerdict')||{style:{}}).style.background = margin>10?'rgba(0,229,160,.08)':margin>0?'rgba(255,184,0,.08)':'rgba(255,68,102,.08)'; (document.getElementById('modalVerdict')||{style:{}}).style.color = mc; document.getElementById('modalVerdict').innerHTML = `${verdict}  |  ${fmt(margin,1)} dB margin  |  ${selectedMode}  |  ${dist.toFixed(1)} km  |  ${freqMHz >= 1000 ? (freqMHz/1000).toFixed(freqMHz%1000?3:0)+' GHz' : freqMHz+' MHz'}  |  ${losTag}`; // LOS tag in terrain header const losTagEl = document.getElementById('modalLosTag'); if (losTagEl) { losTagEl.textContent = losTag; losTagEl.style.color = tAnalysis.losOk ? 'var(--ok)' : 'var(--danger)'; losTagEl.style.borderColor = tAnalysis.losOk ? 'var(--ok)' : 'var(--danger)'; } } function drawModalCompass() { const refSt = getRefStation(); const tgt = selectedStation; const cvs = document.getElementById('modalCompassCanvas'); if (!cvs || !refSt || !tgt) return; const llA = gridToLL(refSt.grid), llB = gridToLL(tgt.grid); if (!llA || !llB) return; const fwdBrng = bearing(llA.lat, llA.lon, llB.lat, llB.lon); const revBrng = (fwdBrng + 180) % 360; // Update labels const fl = document.getElementById('compassFwdLabel'); const rl = document.getElementById('compassRevLabel'); if (fl) fl.textContent = fwdBrng.toFixed(1) + '°'; if (rl) rl.textContent = revBrng.toFixed(1) + '°'; const W = 180, H = 180; const cx = W/2, cy = H/2, r = 78; const dpr = window.devicePixelRatio || 1; cvs.width = W * dpr; cvs.height = H * dpr; cvs.style.width = W + 'px'; cvs.style.height = H + 'px'; const ctx = cvs.getContext('2d'); ctx.scale(dpr, dpr); // Background circle ctx.fillStyle = '#020508'; ctx.beginPath(); ctx.arc(cx, cy, r+4, 0, Math.PI*2); ctx.fill(); // Outer ring ctx.strokeStyle = 'rgba(0,200,255,0.25)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2); ctx.stroke(); // Inner ring ctx.strokeStyle = 'rgba(0,200,255,0.08)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(cx, cy, r*0.6, 0, Math.PI*2); ctx.stroke(); // Cardinal tick marks for (let i = 0; i < 36; i++) { const a = (i * 10 - 90) * Math.PI / 180; const r1 = i % 9 === 0 ? r - 14 : i % 3 === 0 ? r - 9 : r - 5; const r2 = r; ctx.strokeStyle = i % 9 === 0 ? 'rgba(0,200,255,0.6)' : 'rgba(0,200,255,0.2)'; ctx.lineWidth = i % 9 === 0 ? 1.5 : 0.8; ctx.beginPath(); ctx.moveTo(cx + r1*Math.cos(a), cy + r1*Math.sin(a)); ctx.lineTo(cx + r2*Math.cos(a), cy + r2*Math.sin(a)); ctx.stroke(); } // Cardinal labels const cards = [['N',0],['E',90],['S',180],['W',270]]; ctx.font = 'bold 11px Orbitron,monospace'; ctx.fillStyle = 'rgba(0,200,255,0.7)'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; cards.forEach(([lbl, deg]) => { const a = (deg - 90) * Math.PI / 180; ctx.fillText(lbl, cx + (r-22)*Math.cos(a), cy + (r-22)*Math.sin(a)); }); // Helper: draw arrow needle function drawNeedle(deg, color, length, width) { const a = (deg - 90) * Math.PI / 180; const tipX = cx + length * Math.cos(a); const tipY = cy + length * Math.sin(a); const tailX= cx - (length*0.35) * Math.cos(a); const tailY= cy - (length*0.35) * Math.sin(a); const perpA= a + Math.PI/2; const hw = width / 2; ctx.beginPath(); ctx.moveTo(tipX, tipY); ctx.lineTo(tailX + hw*Math.cos(perpA), tailY + hw*Math.sin(perpA)); ctx.lineTo(tailX - hw*Math.cos(perpA), tailY - hw*Math.sin(perpA)); ctx.closePath(); ctx.fillStyle = color; ctx.fill(); // Glow ctx.shadowColor = color; ctx.shadowBlur = 8; ctx.fill(); ctx.shadowBlur = 0; } // Return (red) arrow — drawn first so forward is on top drawNeedle(revBrng, 'rgba(255,68,102,0.7)', r*0.52, 7); // Forward (green) arrow drawNeedle(fwdBrng, '#00e5a0', r*0.68, 9); // Center dot ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(cx, cy, 4, 0, Math.PI*2); ctx.fill(); // Degree labels on needles ctx.font = 'bold 10px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const fwdA = (fwdBrng - 90) * Math.PI / 180; const revA = (revBrng - 90) * Math.PI / 180; ctx.fillStyle = '#00e5a0'; ctx.fillText(fwdBrng.toFixed(0)+'°', cx + (r*0.42)*Math.cos(fwdA), cy + (r*0.42)*Math.sin(fwdA)); ctx.fillStyle = 'rgba(255,68,102,0.9)'; ctx.fillText(revBrng.toFixed(0)+'°', cx + (r*0.3)*Math.cos(revA), cy + (r*0.3)*Math.sin(revA)); // Station labels outside ring ctx.font = 'bold 9px Orbitron,monospace'; ctx.fillStyle = '#00e5a0'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const labelR = r + 10; ctx.fillText(tgt.callsign, cx + labelR * Math.cos(fwdA), cy + labelR * Math.sin(fwdA)); ctx.fillStyle = 'rgba(255,184,0,0.8)'; ctx.fillText(refSt.callsign, cx + labelR * Math.cos(revA), cy + labelR * Math.sin(revA)); } function drawModalTerrain() { const refSt = getRefStation(); const tgt = selectedStation; if (!refSt || !tgt) return; const cvs = document.getElementById('modalTerrainCanvas'); if (!cvs || cvs.offsetWidth === 0) return; const freqMHz = parseInt(document.getElementById('modalBandSel')?.value) || 10368; const key = terrainCacheKey(refSt.id || 'ref', tgt.id); const tPts = terrainCache[key]?.points || terrainPoints; const dpr = window.devicePixelRatio || 1; const W = cvs.offsetWidth, H = cvs.offsetHeight || 400; cvs.width = W * dpr; cvs.height = H * dpr; cvs.style.height = H + 'px'; const ctx = cvs.getContext('2d'); ctx.scale(dpr, dpr); if (!tPts || tPts.length < 3) { ctx.fillStyle = '#020508'; ctx.fillRect(0,0,W,H); ctx.fillStyle = 'var(--faint)'; ctx.font = '14px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('Terrain data loading… click a station to fetch', W/2, H/2); document.getElementById('modalTerrainInfo').textContent = 'No terrain data — select a station to fetch SRTM elevation.'; return; } const llA = gridToLL(refSt.grid), llB = gridToLL(tgt.grid); const htx = 5, hrx = 5; const PL=60,PR=16,PT=28,PB=40, pw=W-PL-PR, ph=H-PT-PB; ctx.fillStyle='#020508'; ctx.fillRect(0,0,W,H); const elevs = tPts.map(p=>p.elev); const maxD = tPts[tPts.length-1].dist; const elTx = tPts[0].elev + htx; const elRx = tPts[tPts.length-1].elev + hrx; const lam = 0.3 / (freqMHz/1000); // Fresnel max radius for scale const frMax = Math.max(...tPts.map(p => { const d1=p.dist, d2=maxD-p.dist; if(d1<=0||d2<=0) return 0; return Math.sqrt(lam * d1*1000 * d2*1000 / ((d1+d2)*1000)); })); const losMaxEl = Math.max(elTx, elRx); // Include scatter heights in scale const _mShowAC = document.getElementById('showAcScatter')?.checked; const _mShowTrp = document.getElementById('showTropo')?.checked; const _mAcFL = parseFloat(document.getElementById('acFL')?.value||300); const _mTropoH = parseFloat(document.getElementById('tropoH')?.value||1500); const _mAcAlt = _mAcFL * 100 * 0.3048; const _mTropoMax = _mShowTrp ? Math.max(...elevs) + _mTropoH + 100 : 0; const _mAcMax = _mShowAC ? _mAcAlt + 200 : 0; const maxEl = Math.max(...elevs, losMaxEl + frMax*1.5, _mAcMax, _mTropoMax) + 20; const minEl = Math.min(...elevs) - 30; const elRange = maxEl - minEl || 1; const xp = d => PL + d/maxD * pw; const yp = e => PT + ph * (1 - (e-minEl)/elRange); // Grid lines ctx.strokeStyle='rgba(0,100,180,0.1)'; ctx.lineWidth=1; [0,.25,.5,.75,1].forEach(f => { const yy = PT + ph*f; ctx.beginPath(); ctx.moveTo(PL,yy); ctx.lineTo(W-PR,yy); ctx.stroke(); const elv = maxEl - f*(maxEl-minEl); ctx.fillStyle='#3d5a78'; ctx.font='10px monospace'; ctx.textAlign='right'; ctx.textBaseline='middle'; ctx.fillText(Math.round(elv)+'m', PL-4, yy); }); [0,.25,.5,.75,1].forEach(f => { const xx = PL + pw*f; ctx.beginPath(); ctx.moveTo(xx,PT); ctx.lineTo(xx,H-PB); ctx.stroke(); ctx.fillStyle='#3d5a78'; ctx.font='10px monospace'; ctx.textAlign='center'; ctx.textBaseline='top'; ctx.fillText((maxD*f).toFixed(0)+'km', xx, H-PB+5); }); // Terrain fill ctx.beginPath(); ctx.moveTo(xp(tPts[0].dist), yp(tPts[0].elev)); tPts.forEach(p => ctx.lineTo(xp(p.dist), yp(p.elev))); ctx.lineTo(xp(maxD), H-PB); ctx.lineTo(PL, H-PB); ctx.closePath(); const tg = ctx.createLinearGradient(0,PT,0,H-PB); tg.addColorStop(0,'rgba(0,120,60,0.6)'); tg.addColorStop(1,'rgba(0,40,15,0.2)'); ctx.fillStyle=tg; ctx.fill(); // Terrain line ctx.strokeStyle='rgba(0,229,160,0.7)'; ctx.lineWidth=2; ctx.beginPath(); tPts.forEach((p,i) => i ? ctx.lineTo(xp(p.dist),yp(p.elev)) : ctx.moveTo(xp(p.dist),yp(p.elev))); ctx.stroke(); // Fresnel zone upper boundary ctx.strokeStyle='rgba(255,184,0,0.5)'; ctx.lineWidth=1.5; ctx.setLineDash([5,4]); ctx.beginPath(); let fresnelDrawn = false; tPts.forEach((p,i) => { const d1=p.dist, d2=maxD-p.dist; if(d1<=0||d2<=0) return; const losH = elTx + (elRx-elTx)*d1/maxD; const fr = Math.sqrt(lam * d1*1000 * d2*1000 / ((d1+d2)*1000)); const yy = yp(losH + fr); if(!fresnelDrawn){ctx.moveTo(xp(d1),yy);fresnelDrawn=true;} else ctx.lineTo(xp(d1),yy); }); ctx.stroke(); ctx.setLineDash([]); // Fresnel lower boundary ctx.strokeStyle='rgba(255,184,0,0.25)'; ctx.lineWidth=1; ctx.setLineDash([3,5]); ctx.beginPath(); fresnelDrawn=false; tPts.forEach(p => { const d1=p.dist, d2=maxD-p.dist; if(d1<=0||d2<=0) return; const losH = elTx + (elRx-elTx)*d1/maxD; const fr = Math.sqrt(lam * d1*1000 * d2*1000 / ((d1+d2)*1000)); const yy = yp(losH - fr); if(!fresnelDrawn){ctx.moveTo(xp(d1),yy);fresnelDrawn=true;} else ctx.lineTo(xp(d1),yy); }); ctx.stroke(); ctx.setLineDash([]); // LOS line ctx.strokeStyle='rgba(0,200,255,0.7)'; ctx.lineWidth=2; ctx.beginPath(); ctx.moveTo(xp(0),yp(elTx)); ctx.lineTo(xp(maxD),yp(elRx)); ctx.stroke(); // Obstruction markers tPts.forEach(p => { const losH = elTx + (elRx-elTx)*p.dist/maxD; const d1=p.dist, d2=maxD-p.dist; if(d1<=0||d2<=0) return; const fr = Math.sqrt(lam * d1*1000 * d2*1000 / ((d1+d2)*1000)); if(p.elev > losH) { ctx.fillStyle='rgba(255,68,102,0.3)'; ctx.fillRect(xp(p.dist)-1, yp(p.elev), 3, yp(losH)-yp(p.elev)); } else if(p.elev > losH-fr) { ctx.fillStyle='rgba(255,184,0,0.15)'; ctx.fillRect(xp(p.dist)-1, yp(p.elev), 3, 4); } }); // Station markers [[0,elTx,refSt.callsign,'#ffb800'],[maxD,elRx,tgt.callsign,'#00c8ff']].forEach(([d,el,lbl,col]) => { ctx.fillStyle=col; ctx.beginPath(); ctx.arc(xp(d),yp(el),6,0,Math.PI*2); ctx.fill(); ctx.fillStyle='#fff'; ctx.font='bold 11px Orbitron,monospace'; ctx.textAlign='center'; ctx.fillText(lbl, xp(d), yp(el) + (d===0?-14:20)); }); // Scatter overlays (read from small panel selectors, shared state) const showAC2 = _mShowAC; const showTropo2 = _mShowTrp; const acFL2 = _mAcFL; const tropoH2 = _mTropoH; const acAlt2 = _mAcAlt; if (showAC2) { const acY2 = yp(acAlt2); if (acY2 >= PT && acY2 <= H-PB) { ctx.strokeStyle='rgba(255,153,0,0.85)';ctx.lineWidth=2;ctx.setLineDash([12,6]); ctx.beginPath();ctx.moveTo(PL,acY2);ctx.lineTo(W-PR,acY2);ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle='rgba(255,153,0,0.9)';ctx.font='bold 11px monospace'; ctx.textAlign='right';ctx.textBaseline='middle'; ctx.fillText('✈ FL'+acFL2+' ('+Math.round(acAlt2)+'m MSL)',W-PR-4,acY2-10); ctx.fillStyle='rgba(255,153,0,0.04)'; ctx.fillRect(PL,acY2,pw,H-PB-acY2); } } if (showTropo2 && tPts) { ctx.strokeStyle='rgba(204,136,255,0.8)';ctx.lineWidth=2;ctx.setLineDash([8,6]); ctx.beginPath();let tropoStarted2=false; tPts.forEach(p => { const ty=yp(p.elev+tropoH2); if(!tropoStarted2){ctx.moveTo(xp(p.dist),ty);tropoStarted2=true;} else ctx.lineTo(xp(p.dist),ty); }); ctx.stroke();ctx.setLineDash([]); const mid2=tPts[Math.floor(tPts.length/2)]; if(mid2){ ctx.fillStyle='rgba(204,136,255,0.9)';ctx.font='bold 11px monospace'; ctx.textAlign='center';ctx.textBaseline='bottom'; ctx.fillText('📡 Tropo +'+tropoH2+'m AGL',xp(mid2.dist),yp(mid2.elev+tropoH2)-6); } } // Legend const legendItems = [ ['─','rgba(0,200,255,0.7)','LOS'], ['─','rgba(0,229,160,0.7)','Terrain'], ['- -','rgba(255,184,0,0.7)','1st Fresnel'], ...(showAC2?[['- -','rgba(255,153,0,0.9)','AC Scatter FL'+acFL2]]:[]), ...(showTropo2?[['- -','rgba(204,136,255,0.9)','Tropo +'+tropoH2+'m']]:[]) ]; ctx.font='10px monospace'; legendItems.forEach(([sym,col,lbl],i) => { ctx.fillStyle=col; ctx.textAlign='left'; const col2 = i < 3 ? i*120 : (i-3)*150; ctx.fillText(sym+' '+lbl, PL + (i < 3 ? i*120 : (i-3)*160), i < 3 ? PT+15 : PT+28); }); // Terrain analysis info const analysis = analyseTerrainPath(tPts, freqMHz); const llAg = gridToLL(refSt.grid), llBg = gridToLL(tgt.grid); document.getElementById('modalTerrainInfo').innerHTML = `` + (analysis.losOk ? '✓ Line of Sight Clear' : '✗ LOS Obstructed') + '' + `  |  Fresnel: ${(analysis.fresnelMinPct||0).toFixed(0)}% clearance` + (analysis.penaltyDB > 0.1 ? `  |  Penalty: +${analysis.penaltyDB.toFixed(1)} dB` : '') + `  |  ${freqMHz >= 1000 ? (freqMHz/1000).toFixed(freqMHz%1000?3:0)+' GHz' : freqMHz+' MHz'}` + `  |  Terrain data from open-elevation.com`; } // Redraw modal terrain on window resize window.addEventListener('resize', () => { if ((document.getElementById('detailModal')||{style:{}}).style.display !== 'none') { setTimeout(drawModalTerrain, 100); } }); `); win.document.close(); } function dl(content,filename,type) { const blob=new Blob([content],{type}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=filename; a.click(); } // ═══════════════════════════════════════════════════════════════════ // CHAT SYSTEM // ═══════════════════════════════════════════════════════════════════ const CHAT_URL = 'uwave-chat.php'; const CHAT_POLL_MS = 5000; let chatLastId = 0; let chatTimer = null; let chatMyCall = '', chatMyName = '', chatMyGrid = ''; function chatIdentity() { const call = (document.getElementById('refCall').value || '').toUpperCase().trim(); const grid = (document.getElementById('refGrid').value || '').toUpperCase().trim(); const st = stations.find(s => s.callsign.toUpperCase() === call); chatMyCall = call || 'ANON'; chatMyName = st ? (st.name || '') : ''; chatMyGrid = grid || (st ? st.grid : '') || ''; const ce = document.getElementById('chatMyCall'); const ge = document.getElementById('chatMyGrid'); if (ce) ce.textContent = chatMyCall; if (ge) ge.textContent = chatMyGrid ? '(' + chatMyGrid + ')' : ''; } async function chatPoll() { chatIdentity(); try { const url = CHAT_URL + '?action=get&since=' + chatLastId + '&callsign=' + encodeURIComponent(chatMyCall) + '&name=' + encodeURIComponent(chatMyName) + '&grid=' + encodeURIComponent(chatMyGrid); const resp = await fetch(url, { cache: 'no-store' }); if (!resp.ok) throw new Error('HTTP ' + resp.status); const data = await resp.json(); renderOnline(data.online || []); if (data.messages && data.messages.length) { appendMessages(data.messages); chatLastId = Math.max(chatLastId, ...data.messages.map(m => m.id)); } const se = document.getElementById('chatStatus'); if (se) se.textContent = ''; } catch(e) { const se = document.getElementById('chatStatus'); if (se) se.textContent = 'Chat: ' + e.message; } chatTimer = setTimeout(chatPoll, CHAT_POLL_MS); } function renderOnline(users) { const ce = document.getElementById('chatOnlineCount'); const le = document.getElementById('chatOnlineList'); if (ce) ce.textContent = users.length; if (!le) return; le.innerHTML = users.map(u => { const me = u.callsign.toUpperCase() === chatMyCall.toUpperCase(); return '' + u.callsign + ''; }).join(''); } function appendMessages(msgs) { const box = document.getElementById('chatMsgs'); if (!box) return; const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 80; const ph = box.querySelector('.chat-sys'); if (ph && msgs.length) ph.remove(); msgs.forEach(m => { const me = m.callsign.toUpperCase() === chatMyCall.toUpperCase(); const wrap = document.createElement('div'); wrap.className = 'chat-msg' + (me ? ' me' : ''); const meta = document.createElement('div'); meta.className = 'chat-meta'; meta.innerHTML = '' + m.callsign + '' + (m.name ? '' + m.name + '' : '') + (m.grid ? '' + m.grid + '' : '') + '' + (m.tsStr || '') + ''; const bub = document.createElement('div'); bub.className = 'chat-bubble'; bub.textContent = m.text; wrap.appendChild(meta); wrap.appendChild(bub); box.appendChild(wrap); }); if (atBottom) box.scrollTop = box.scrollHeight; } async function sendChatMsg() { chatIdentity(); const inp = document.getElementById('chatInput'); const text = (inp ? inp.value : '').trim(); if (!text) return; if (!chatMyCall || chatMyCall === 'ANON') { const se = document.getElementById('chatStatus'); if (se) se.textContent = 'Set Reference Callsign first'; document.getElementById('refCall').focus(); return; } const btn = document.getElementById('chatSendBtn'); if (btn) btn.disabled = true; if (inp) inp.value = ''; const se = document.getElementById('chatStatus'); if (se) se.textContent = 'Sending...'; try { const resp = await fetch(CHAT_URL + '?action=post', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Save-Token': SAVE_TOKEN }, body: JSON.stringify({ callsign: chatMyCall, name: chatMyName, grid: chatMyGrid, text }) }); const result = await resp.json(); if (!result.ok) throw new Error(result.error || 'Failed'); if (se) se.textContent = ''; clearTimeout(chatTimer); chatPoll(); } catch(e) { if (se) se.textContent = 'Send failed: ' + e.message; if (inp) inp.value = text; } finally { if (btn) btn.disabled = false; if (inp) inp.focus(); } } function chatKeyDown(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChatMsg(); } } async function clearChat() { if (!confirm('Clear all chat messages?')) return; try { await fetch(CHAT_URL + '?action=clear', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Save-Token': SAVE_TOKEN }, body: JSON.stringify({}) }); const box = document.getElementById('chatMsgs'); if (box) { box.innerHTML = ''; const d = document.createElement('div'); d.className='chat-sys'; d.textContent='Chat cleared'; box.appendChild(d); } chatLastId = 0; } catch(e) { alert('Clear failed: ' + e.message); } } function startChat() { chatIdentity(); if (!chatTimer) chatPoll(); } // ═══════════════════════════════════════════════════════════════════ // INIT // ═══════════════════════════════════════════════════════════════════ document.addEventListener('DOMContentLoaded', () => { // Restore admin mode within the same browser session if (sessionStorage.getItem('uwp_admin') === '1') { document.body.classList.add('admin-mode'); } // Splitters run in their own handler so a map error can never block them initSplitters(); }); document.addEventListener('DOMContentLoaded', async ()=>{ // Init map FIRST before anything else loadActiveStations(); initMap(); // Then force re-layout after browser paint requestAnimationFrame(() => { if(map) map.invalidateSize(false); }); setTimeout(() => { if(map) map.invalidateSize(false); }, 200); setTimeout(() => { if(map) map.invalidateSize(false); }, 800); renderStationList(); buildMatrix(); // Demo stations for first run const demo=[ {id:'d1',callsign:'K6VHF',name:'Alex',grid:'DN31ux',bands:[ {mhz:10368,label:'10 GHz',alias:'3cm',power:5,antType:'Dish',dishSize:0.6,antGain:parseFloat(dishGain(0.6,10368).toFixed(1)),cableLoss:.5}, {mhz:5760,label:'5.7 GHz',alias:'6cm',power:10,antType:'Dish',dishSize:0.6,antGain:parseFloat(dishGain(0.6,5760).toFixed(1)),cableLoss:.5}, {mhz:1296,label:'1.3 GHz',alias:'23cm',power:100,antType:'Yagi',yElements:11,antGain:parseFloat(yagiGain(11).toFixed(1)),cableLoss:1}, ],comms:['Cell Phone','2m Net'],phone:'',email:'',repeater:'146.520',dmr:'',notes:''}, {id:'d2',callsign:'W6ABC',name:'Jim',grid:'DM04',bands:[ {mhz:10368,label:'10 GHz',alias:'3cm',power:3,antType:'Horn',dishSize:0.25,antGain:parseFloat(dishGain(0.25,10368,0.75).toFixed(1)),cableLoss:.3}, {mhz:1296,label:'1.3 GHz',alias:'23cm',power:50,antType:'Loop Yagi',yElements:16,antGain:parseFloat(yagiGain(16).toFixed(1)),cableLoss:1.5}, ],comms:['Cell Phone','Echolink'],phone:'',email:'',repeater:'',dmr:'',notes:''}, {id:'d3',callsign:'N7XYZ',name:'Bob',grid:'DN40',bands:[ {mhz:10368,label:'10 GHz',alias:'3cm',power:8,antType:'Dish',dishSize:0.9,antGain:parseFloat(dishGain(0.9,10368).toFixed(1)),cableLoss:.4}, {mhz:5760,label:'5.7 GHz',alias:'6cm',power:5,antType:'Dish',dishSize:0.9,antGain:parseFloat(dishGain(0.9,5760).toFixed(1)),cableLoss:.5}, {mhz:24048,label:'24 GHz',alias:'1.25cm',power:.5,antType:'Dish',dishSize:0.3,antGain:parseFloat(dishGain(0.3,24048).toFixed(1)),cableLoss:.8}, ],comms:['DMR','2m Net'],phone:'',email:'',repeater:'',dmr:'310123',notes:'Operating from 8am-6pm MDT'}, ]; startChat(); logVisit();; // Try to load from server first; fall back to demo data if not found setLoadingStatus('Connecting to server database…'); const serverLoaded = await fetchServerDB(false); if (!serverLoaded) { // No server DB found — load demo data stations = demo; document.getElementById('refCall').value = 'K6VHF'; document.getElementById('refGrid').value = 'DN31ux'; onRefChange(); renderStationList(); refreshMarkers(); buildMatrix(); const bounds = stations.map(s=>gridToLL(s.grid)).filter(Boolean).map(ll=>[ll.lat,ll.lon]); if (bounds.length) map.fitBounds(L.latLngBounds(bounds).pad(0.4)); setLoadingStatus('Demo data loaded — upload uwave-stations.json to server to share with participants', 'warn'); } });
🔐 Authentication Required
This action is protected.
Enter the operator PIN to continue.
Delete Station