collage failed to load: ' + esc(e.message) + '
'; + }); - // ---- UI bindings ---- - var slider = document.getElementById('slider'); - var winPick = document.getElementById('winPick'); - var views = document.querySelectorAll('.view'); + // ---- UI ---- + function $(id) { return document.getElementById(id); } function syncPill(container) { + if (!container) return; var pill = container.querySelector('.seg-pill'); var active = container.querySelector('button[aria-current="true"]'); if (!pill || !active) return; @@ -69,11 +81,15 @@ pill.style.transform = 'translateX(' + active.offsetLeft + 'px)'; } function syncAllPills() { - [slider, winPick, document.getElementById('atlasSort')].forEach(function (c) { if (c) syncPill(c); }); + [$('slider'), $('winPick'), $('atlasSort')].forEach(syncPill); } function bindUI() { - // View slider - [].slice.call(slider.querySelectorAll('button')).forEach(function (b) { + var slider = $('slider'); + var winPick = $('winPick'); + var atlasEl = $('atlasSort'); + var views = document.querySelectorAll('.view'); + + if (slider) [].slice.call(slider.querySelectorAll('button')).forEach(function (b) { b.addEventListener('click', function () { [].slice.call(slider.querySelectorAll('button')).forEach(function (x) { x.setAttribute('aria-current', x === b ? 'true' : 'false'); @@ -86,8 +102,8 @@ if (i === 2) renderAtlas(); }); }); - // Window picker - [].slice.call(winPick.querySelectorAll('button')).forEach(function (b) { + + if (winPick) [].slice.call(winPick.querySelectorAll('button')).forEach(function (b) { b.setAttribute('aria-current', (+b.dataset.h === currentHours) ? 'true' : 'false'); b.addEventListener('click', function () { [].slice.call(winPick.querySelectorAll('button')).forEach(function (x) { @@ -99,9 +115,8 @@ refreshRecent(); }); }); - // Atlas sort - var atlasEl = document.getElementById('atlasSort'); - [].slice.call(atlasEl.querySelectorAll('button')).forEach(function (b) { + + if (atlasEl) [].slice.call(atlasEl.querySelectorAll('button')).forEach(function (b) { b.addEventListener('click', function () { [].slice.call(atlasEl.querySelectorAll('button')).forEach(function (x) { x.setAttribute('aria-current', x === b ? 'true' : 'false'); @@ -111,16 +126,16 @@ renderAtlas(); }); }); - // About modal - document.getElementById('aboutLink').addEventListener('click', function () { - document.getElementById('about-modal').setAttribute('aria-hidden', 'false'); + + var aboutLink = $('aboutLink'); + var aboutModal = $('about-modal'); + if (aboutLink && aboutModal) { + aboutLink.addEventListener('click', function () { aboutModal.setAttribute('aria-hidden', 'false'); }); + } + document.querySelectorAll('#about-modal [data-close]').forEach(function (el) { + el.addEventListener('click', function () { aboutModal && aboutModal.setAttribute('aria-hidden', 'true'); }); }); - document.querySelectorAll('[data-close]').forEach(function (el) { - el.addEventListener('click', function () { - document.getElementById('about-modal').setAttribute('aria-hidden', 'true'); - }); - }); - // Resize: re-pack collage and re-pill + var rT; window.addEventListener('resize', function () { clearTimeout(rT); @@ -137,10 +152,10 @@ function refreshAll() { var h = currentHours; return Promise.all([ - fetchJson(api('/api/lifelist.json')).catch(function () { return null; }), - fetchJson(api('/api/firstseen.json')).catch(function () { return null; }), - fetchJson(api('/api/timeseries.json?days=30')).catch(function () { return null; }), - fetchJson(api('/api/recent.json?hours=' + h)).catch(function () { return null; }), + fetchJson(api('lifelist')).catch(function () { return null; }), + fetchJson(api('firstseen')).catch(function () { return null; }), + fetchJson(api('timeseries', 'days=30')).catch(function () { return null; }), + fetchJson(api('recent', 'hours=' + h)).catch(function () { return null; }), ]).then(function (parts) { DATA.lifelist = parts[0]; DATA.firstseen = parts[1]; @@ -152,7 +167,7 @@ } function refreshRecent() { var h = currentHours; - return fetchJson(api('/api/recent.json?hours=' + h)).then(function (j) { + return fetchJson(api('recent', 'hours=' + h)).then(function (j) { if (h !== currentHours) return; DATA.recent = j; renderCollageFromData(); @@ -160,9 +175,10 @@ }).catch(function () {}); } - // ---- Mask + slug helpers ---- + // ---- Slug + mask helpers ---- var maskCache = {}; function loadMask(slug) { + if (!MASKS) return null; if (maskCache[slug]) return maskCache[slug]; var rec = MASKS[slug]; if (!rec) return null; @@ -182,17 +198,16 @@ return sci.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } function aspect(sci) { - var d = DIMS[slugify(sci)]; + var d = DIMS && DIMS[slugify(sci)]; return d ? d[0] / d[1] : 1.4; } + function imgUrl(sci, com) { + return media('cutout.php', 'sci=' + encodeURIComponent(sci) + + (com ? '&com=' + encodeURIComponent(com) : '') + + '&v=' + IMG_VERSION); + } // ---- Collage layout (mask-aware, viewport-budget) ---- - // - // Each species ships a low-res alpha mask. We maintain an occupancy - // grid at viewport resolution; for each tile we spiral outward from - // the cluster center and pick the closest non-overlapping placement. - // Total area is normalised so the entire cluster fits any viewport, - // and we iterate shrink+repack until every bird lands on-screen. function tuning(n) { return { packingBudgetFrac: n <= 4 ? 0.46 : n <= 12 ? 0.40 : n <= 24 ? 0.34 : 0.28, @@ -278,14 +293,17 @@ } function renderCollageFromData() { - var items = (DATA.recent && DATA.recent.species) || []; - renderCollage(items); + renderCollage((DATA.recent && DATA.recent.species) || []); } function renderCollage(items) { - var collage = document.getElementById('collage'); + var collage = $('collage'); + if (!collage) return; collage.innerHTML = ''; if (!items.length) { - collage.innerHTML = 'no birds heard in this window.
'; + var p = document.createElement('p'); + p.className = 'empty'; + p.textContent = 'no birds heard in this window.'; + collage.appendChild(p); return; } var W = collage.clientWidth, H = collage.clientHeight; @@ -297,13 +315,15 @@ var minArea = vpArea * T.minTileAreaFrac; var tiles = items.map(function (s) { - var slug = slugify(s.sci); + var slug = slugify(s.sci || ''); var mask = loadMask(slug); if (!mask) return null; var n = +s.n; if (!n || isNaN(n)) n = 1; return { mask: mask, data: s, ar: aspect(s.sci), score: Math.pow(Math.max(1, n), T.countExp) }; }).filter(Boolean); + if (!tiles.length) { collage.innerHTML = ''; return; } + var sumScore = tiles.reduce(function (a, t) { return a + t.score; }, 0) || 1; tiles.forEach(function (t) { t.area = Math.max(minArea, budget * t.score / sumScore); }); var sumA = tiles.reduce(function (a, t) { return a + t.area; }, 0); @@ -353,30 +373,35 @@ placed.forEach(function (t) { if (t.x > -1000) { t.x += dx; t.y += dy; } }); } + // Build tiles via createElement / textContent / setAttribute so user- + // editable species names from labels.txt can't smuggle markup. placed.forEach(function (t) { var s = t.data; - var img = api('/api/img?sci=' + encodeURIComponent(s.sci) + - (s.com ? '&com=' + encodeURIComponent(s.com) : '') + - '&v=' + IMG_VERSION); + var label = s.com || s.sci; var btn = document.createElement('button'); btn.className = 'gtile'; btn.type = 'button'; - btn.setAttribute('data-sci', s.sci); - btn.setAttribute('aria-label', s.com || s.sci); - btn.title = (s.com || s.sci) + ' · ' + (+s.n || 0) + ' calls'; + btn.dataset.sci = s.sci; + btn.setAttribute('aria-label', label); + btn.title = label + ' · ' + (+s.n || 0) + ' calls'; btn.style.left = t.x + 'px'; btn.style.top = t.y + 'px'; btn.style.width = t.fullW + 'px'; btn.style.height = t.fullH + 'px'; - btn.innerHTML = 'no species yet — atlas fills in as the Pi detects birds.
'; return; } + if (!sp.length) { + var p = document.createElement('p'); + p.className = 'empty'; + p.textContent = 'no species yet — atlas fills in as the Pi detects birds.'; + list.innerHTML = ''; + list.appendChild(p); + return; + } var sort = readLS('av:atlasSort', 'count'); if (sort === 'count') sp.sort(function (a, b) { return (+b.n || 0) - (+a.n || 0); }); else if (sort === 'recent') sp.sort(function (a, b) { @@ -414,49 +453,66 @@ else sp.sort(function (a, b) { return Date.parse((a.first_seen || '').replace(' ', 'T')) - Date.parse((b.first_seen || '').replace(' ', 'T')); }); - list.innerHTML = sp.map(function (s) { - var img = api('/api/img?sci=' + encodeURIComponent(s.sci) + - (s.com ? '&com=' + encodeURIComponent(s.com) : '') + - '&v=' + IMG_VERSION); - return ''; - }).join(''); - [].slice.call(list.querySelectorAll('.atlas-card')).forEach(function (b) { - b.addEventListener('click', function () { openDetail({ sci: b.dataset.sci }); }); + list.innerHTML = ''; + sp.forEach(function (s) { + var btn = document.createElement('button'); + btn.className = 'atlas-card'; + btn.type = 'button'; + btn.dataset.sci = s.sci; + var img = document.createElement('img'); + img.loading = 'lazy'; + img.decoding = 'async'; + img.alt = s.com || s.sci; + img.src = imgUrl(s.sci, s.com); + var name = document.createElement('span'); + name.className = 'atlas-name'; + name.textContent = s.com || s.sci; + var count = document.createElement('span'); + count.className = 'atlas-count'; + count.textContent = String(+s.n || 0); + btn.appendChild(img); + btn.appendChild(name); + btn.appendChild(count); + btn.addEventListener('click', function () { openDetail(s); }); + list.appendChild(btn); }); } - // ---- Detail modal (basic — opens recording + spectrogram for a species) ---- + // ---- Detail modal ---- function openDetail(s) { var sci = s.sci || s; - // Lookup full record from lifelist if available var rec = ((DATA.lifelist && DATA.lifelist.species) || []).find(function (x) { return x.sci === sci; }) || s; - var img = api('/api/img?sci=' + encodeURIComponent(sci) + - (rec.com ? '&com=' + encodeURIComponent(rec.com) : '') + - '&v=' + IMG_VERSION); - var rec_url = api('/api/recording?sci=' + encodeURIComponent(sci)); - var spec_url = api('/api/spectrogram?sci=' + encodeURIComponent(sci)); - var modal = document.getElementById('detail-modal'); + var label = rec.com || sci; + + var modal = $('detail-modal'); if (!modal) { modal = document.createElement('div'); modal.id = 'detail-modal'; modal.setAttribute('role', 'dialog'); document.body.appendChild(modal); } + // Single innerHTML for the chrome (no user content), then build the + // user-content elements via createElement so labels are safe. modal.innerHTML = '' + '' + sci + '
' + - '' + (+rec.n || 0) + ' calls · last heard ' + (rec.last_seen || '—') + '
' + - ' ' + - '' + + ' ' + + ' ' + + '