Files
k5bss.com/js/k5bss.js
T
JustinandClaude Opus 5.5 3d9c5c4b2b Remove RAW_DATA fallback and show a log-unavailable message
- Drop the embedded 20-contact RAW_DATA sample; the logbook now comes
  only from data/k5bss-log.json
- Show a banner and table message when the log fails to load, and
  display "—" for the header stats
- Skip building the dashboard when there is no data (it crashed on an
  empty log) and guard the activity chart redraw on resize
- Compute the QSO count from the log instead of hardcoding it

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 10:04:44 -05:00

319 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ─── DATA ───────────────────────────────────────────────────
let ALL_DATA = [];
let filteredData = [];
let sortKey = 'date';
let sortDir = -1; // -1 = desc
let currentPage = 1;
const PAGE_SIZE = 50;
let logUnavailable = false;
// ─── FULL LOG ────────────────────────────────────────────────
const FULL_LOG_URL = 'data/k5bss-log.json';
async function loadFullLog() {
try {
const res = await fetch(FULL_LOG_URL);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
console.warn('Could not load full log:', err);
return null;
}
}
async function init() {
const log = await loadFullLog();
logUnavailable = !log;
ALL_DATA = log || [];
filteredData = [...ALL_DATA];
if (logUnavailable) {
document.getElementById('log-error').hidden = false;
renderTable();
return;
}
// Update stats
const countries = new Set(ALL_DATA.map(r => r.country).filter(Boolean));
const states = new Set(ALL_DATA.filter(r => r.country === 'United States').map(r => r.state).filter(Boolean));
document.getElementById('stat-qso').textContent = ALL_DATA.length.toLocaleString();
document.getElementById('stat-dxcc').textContent = countries.size;
document.getElementById('stat-states').textContent = states.size;
buildDashboard();
populateFilters();
renderTable();
}
// ─── DASHBOARD ───────────────────────────────────────────────
function buildDashboard() {
// Modes
const modes = countBy(ALL_DATA, 'mode');
const topModes = Object.entries(modes).sort((a,b)=>b[1]-a[1]).slice(0,7);
const maxMode = topModes[0][1];
const modesEl = document.getElementById('modes-chart');
const modeColors = { FT8:'#ffaa00', FT4:'#ffcc44', DIG:'#ff8800', USB:'#4499ff', LSB:'#66aaff', SSB:'#88bbff', RTTY:'#ff4455' };
modesEl.innerHTML = topModes.map(([m, c]) => `
<div class="bar-row">
<div class="bar-label">${m}</div>
<div class="bar-track"><div class="bar-fill" style="width:0%;background:${modeColors[m]||'#888'}" data-target="${(c/maxMode*100).toFixed(1)}"></div></div>
<div class="bar-count">${c}</div>
</div>`).join('');
// Bands
const bands = countBy(ALL_DATA, 'band');
const topBands = Object.entries(bands).sort((a,b)=>b[1]-a[1]).slice(0,8);
const maxBand = topBands[0][1];
const bandEl = document.getElementById('bands-chart');
const bandColors = { '20M':'#4499ff','40M':'#00ff88','80M':'#ff4455','10M':'#00ddff','30M':'#88aaff','17M':'#aaddff','15M':'#ffaaff','12M':'#ffdd88' };
bandEl.innerHTML = topBands.map(([b, c]) => `
<div class="bar-row">
<div class="bar-label">${b}</div>
<div class="bar-track"><div class="bar-fill" style="width:0%;background:${bandColors[b]||'#888'}" data-target="${(c/maxBand*100).toFixed(1)}"></div></div>
<div class="bar-count">${c}</div>
</div>`).join('');
// Countries
const countries = countBy(ALL_DATA, 'country');
const topCountries = Object.entries(countries).sort((a,b)=>b[1]-a[1]).slice(0,20);
const ctEl = document.getElementById('countries-list');
ctEl.innerHTML = topCountries.map(([c,n]) => `
<div class="country-row">
<span class="country-name">${c||'Unknown'}</span>
<span class="country-count">${n}</span>
</div>`).join('');
// Animate bars after a tick
requestAnimationFrame(() => {
setTimeout(() => {
document.querySelectorAll('.bar-fill').forEach(el => {
el.style.width = el.dataset.target + '%';
});
}, 100);
});
// Activity chart
drawActivityChart();
}
function drawActivityChart() {
if (!ALL_DATA.length) return;
const canvas = document.getElementById('activityCanvas');
const dpr = window.devicePixelRatio || 1;
const W = canvas.parentElement.offsetWidth - 48;
const H = 120;
canvas.width = W * dpr;
canvas.height = H * dpr;
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
// Group by year-month
const monthly = {};
ALL_DATA.forEach(r => {
if (!r.date) return;
const key = r.date.slice(0,7);
monthly[key] = (monthly[key]||0) + 1;
});
const keys = Object.keys(monthly).sort();
const vals = keys.map(k => monthly[k]);
const max = Math.max(...vals);
const pad = { l: 10, r: 10, t: 15, b: 30 };
const chartW = W - pad.l - pad.r;
const chartH = H - pad.t - pad.b;
const barW = Math.max(2, (chartW / keys.length) - 1);
ctx.clearRect(0,0,W,H);
// Grid line
ctx.strokeStyle = 'rgba(30,37,48,0.8)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(pad.l, pad.t + chartH);
ctx.lineTo(W - pad.r, pad.t + chartH);
ctx.stroke();
// Bars
keys.forEach((k, i) => {
const x = pad.l + i * (chartW / keys.length);
const h = (vals[i] / max) * chartH;
const y = pad.t + chartH - h;
const grad = ctx.createLinearGradient(0, y, 0, y + h);
grad.addColorStop(0, 'rgba(0,255,136,0.9)');
grad.addColorStop(1, 'rgba(0,255,136,0.15)');
ctx.fillStyle = grad;
ctx.fillRect(x, y, barW, h);
});
// X labels (every 6 months)
ctx.fillStyle = 'rgba(90,112,128,0.9)';
ctx.font = `${9 * dpr / dpr}px 'Share Tech Mono', monospace`;
ctx.textAlign = 'center';
keys.forEach((k, i) => {
if (i % 6 === 0) {
const x = pad.l + i * (chartW / keys.length) + barW/2;
ctx.fillText(k, x, H - 8);
}
});
}
// ─── TABLE ───────────────────────────────────────────────────
function populateFilters() {
const bands = [...new Set(ALL_DATA.map(r=>r.band).filter(Boolean))].sort();
const modes = [...new Set(ALL_DATA.map(r=>r.mode).filter(Boolean))].sort();
const bandSel = document.getElementById('filter-band');
const modeSel = document.getElementById('filter-mode');
bands.forEach(b => { const o = document.createElement('option'); o.value=b; o.text=b; bandSel.appendChild(o); });
modes.forEach(m => { const o = document.createElement('option'); o.value=m; o.text=m; modeSel.appendChild(o); });
}
function applyFilters() {
const q = document.getElementById('search-input').value.trim().toLowerCase();
const band = document.getElementById('filter-band').value;
const mode = document.getElementById('filter-mode').value;
const lotw = document.getElementById('filter-lotw').value;
filteredData = ALL_DATA.filter(r => {
if (band && r.band !== band) return false;
if (mode && r.mode !== mode) return false;
if (lotw && r.lotw !== lotw) return false;
if (q) {
const hay = [r.call, r.name, r.country, r.qth, r.state, r.grid].join(' ').toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
currentPage = 1;
renderTable();
}
function sortTable(key) {
if (sortKey === key) sortDir *= -1;
else { sortKey = key; sortDir = key === 'date' || key === 'time' ? -1 : 1; }
filteredData.sort((a,b) => {
let av = a[key]||'', bv = b[key]||'';
if (key === 'dist') { av = parseInt(av)||0; bv = parseInt(bv)||0; }
if (av < bv) return -sortDir;
if (av > bv) return sortDir;
return 0;
});
// Update header indicators
document.querySelectorAll('thead th').forEach(th => {
th.classList.remove('sort-asc','sort-desc');
});
const headers = ['call','name','date','time','band','mode','rst_sent','country','dist','lotw'];
const idx = headers.indexOf(key);
if (idx >= 0) {
const ths = document.querySelectorAll('thead th');
ths[idx].classList.add(sortDir === 1 ? 'sort-asc' : 'sort-desc');
}
renderTable();
}
function renderTable() {
const tbody = document.getElementById('log-tbody');
const total = filteredData.length;
const pages = Math.ceil(total / PAGE_SIZE);
const start = (currentPage - 1) * PAGE_SIZE;
const slice = filteredData.slice(start, start + PAGE_SIZE);
document.getElementById('log-meta').textContent =
`${total.toLocaleString()} QSOs · Page ${currentPage} of ${Math.max(1,pages)}`;
if (slice.length === 0) {
const msg = logUnavailable ? 'LOG UNAVAILABLE — TRY AGAIN LATER' : 'NO CONTACTS FOUND';
tbody.innerHTML = `<tr><td colspan="10" style="text-align:center;padding:2rem;color:var(--muted);font-family:var(--font-mono);font-size:0.8rem;">${msg}</td></tr>`;
} else {
tbody.innerHTML = slice.map(r => {
const timeStr = r.time ? r.time.slice(0,2)+':'+r.time.slice(2,4) : '';
const bandClass = `band-${r.band}`;
const lotwClass = r.lotw === 'Y' ? 'lotw-y' : 'lotw-n';
const lotwSym = r.lotw === 'Y' ? '✓' : '·';
const rst = r.rst_sent && r.rst_rcvd ? `${r.rst_sent} / ${r.rst_rcvd}` : r.rst_sent || '';
return `<tr>
<td class="call-cell">${esc(r.call)}</td>
<td>${esc(r.name ? titleCase(r.name) : '')}</td>
<td>${esc(r.date)}</td>
<td>${timeStr}Z</td>
<td><span class="band-badge ${bandClass}">${esc(r.band)}</span></td>
<td class="mode-cell">${esc(r.mode)}</td>
<td class="rst-cell">${esc(rst)}</td>
<td>${esc(r.country||'')}${r.state ? ' <span style="color:var(--muted)">'+esc(r.state)+'</span>' : ''}</td>
<td class="dist-cell">${r.dist ? Number(r.dist).toLocaleString() : ''}</td>
<td class="${lotwClass}" style="text-align:center">${lotwSym}</td>
</tr>`;
}).join('');
}
renderPagination(pages);
}
function renderPagination(pages) {
const el = document.getElementById('pagination');
if (pages <= 1) { el.innerHTML = ''; return; }
let html = `<button class="page-btn" onclick="goPage(${currentPage-1})" ${currentPage===1?'disabled':''}>‹ Prev</button>`;
const range = [];
if (pages <= 7) {
for (let i=1;i<=pages;i++) range.push(i);
} else {
range.push(1);
if (currentPage > 3) range.push('...');
for (let i=Math.max(2,currentPage-1); i<=Math.min(pages-1,currentPage+1); i++) range.push(i);
if (currentPage < pages-2) range.push('...');
range.push(pages);
}
range.forEach(p => {
if (p === '...') html += `<span class="page-info">…</span>`;
else html += `<button class="page-btn ${p===currentPage?'active':''}" onclick="goPage(${p})">${p}</button>`;
});
html += `<button class="page-btn" onclick="goPage(${currentPage+1})" ${currentPage===pages?'disabled':''}>Next ›</button>`;
el.innerHTML = html;
}
function goPage(p) {
currentPage = p;
renderTable();
document.querySelector('.table-wrap').scrollIntoView({behavior:'smooth',block:'nearest'});
}
// ─── HELPERS ─────────────────────────────────────────────────
function countBy(arr, key) {
return arr.reduce((acc, r) => {
const v = r[key];
if (v) acc[v] = (acc[v]||0)+1;
return acc;
}, {});
}
function esc(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function titleCase(s) {
return s.toLowerCase().replace(/\b\w/g, c => c.toUpperCase());
}
function showSection(id, btn) {
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
document.getElementById('section-' + id).classList.add('active');
btn.classList.add('active');
}
window.addEventListener('load', init);
window.addEventListener('resize', () => {
if (document.getElementById('section-dashboard').classList.contains('active')) {
drawActivityChart();
}
});