d3241f92cf
Blockers - http://birdnet.local/avian/ now serves the collage (added avian/index.html redirect to frontend/; previously the symlink target had no top-level index, so users saw a directory listing of api/ assets/ forwarding/ frontend/ scripts/). - Gemini model bumped from gemini-2.5-flash-image-preview (deprecated 2026-01-15) to the stable gemini-2.5-flash-image. Pregen was non- functional out of the box. - avian-mqtt.service: dropped %h (which expands to /root for system-mode units regardless of User=). Use WorkingDirectory=~ which DOES respect User=, plus a relative ExecStart. - birdnet-api.php case 'lifelist' now aliases COUNT(*) AS n (was 'total'), matching what apt.js reads for atlas card counts. Atlas previously always displayed 0. Bugs - Retry-After parsing handles HTTP-date format without crashing. - apt.js no longer fetches firstseen/timeseries (declared in state but never consumed). Two wasted round-trips per page load removed. - update_birdnet.sh defaults branch=avian-visitors so the (opt-in) Sunday auto-update cron doesn't reset HEAD to upstream main and delete the avian/ overlay. - recording.php: removed dead $BIRDS_JSON_CANDIDATES. Polish - Pregen --sleep default raised 4→6s (more headroom under free-tier RPM). - en-dash in README BOM (~$35–80) replaced with hyphen for style match. - Slop sweep across user-facing docs: no em-dashes, no en-dashes, no AI-slop word hits (robust/comprehensive/seamless/etc.).
518 lines
20 KiB
JavaScript
518 lines
20 KiB
JavaScript
/* AvianVisitors - bird collage frontend.
|
||
*
|
||
* Three views over BirdNET-Pi detections:
|
||
* collage - mask-packed cluster of species illustrations, sized by
|
||
* count. Layout normalises so every bird always fits on
|
||
* every viewport.
|
||
* stats - per-species mark on a time × count plot.
|
||
* atlas - grid of every species ever detected, with detail modal.
|
||
*
|
||
* Lives at $HOME/BirdNET-Pi/avian/frontend/ on the Pi; the install
|
||
* symlinks $HOME/BirdNET-Pi/avian → $EXTRACTED/avian, so this file
|
||
* loads from http://birdnet.local/avian/frontend/apt.js with the
|
||
* collage at http://birdnet.local/avian/frontend/.
|
||
*
|
||
* All API calls are relative - they target the PHP shims in ../api/
|
||
* served by BirdNET-Pi's existing Caddy + PHP-FPM stack. No frontend
|
||
* configuration needed; works out of the box on any BirdNET-Pi host.
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
// Relative API helpers. Frontend lives at /avian/frontend/; the PHP
|
||
// shims live at /avian/api/. The router PHP (birdnet-api.php)
|
||
// dispatches on ?action= to the recent/lifelist/firstseen/timeseries
|
||
// queries.
|
||
function api(action, qs) {
|
||
return '../api/birdnet-api.php?action=' + action + (qs ? '&' + qs : '');
|
||
}
|
||
function media(file, qs) {
|
||
return '../api/' + file + (qs ? '?' + qs : '');
|
||
}
|
||
|
||
// Cache-bust query for image URLs. Bump after running pregen.py with
|
||
// --force so browsers + CDNs drop their cached copies of every bird.
|
||
var IMG_VERSION = '1';
|
||
|
||
function readLS(k, d) { try { return localStorage.getItem(k) || d; } catch (e) { return d; } }
|
||
function writeLS(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
|
||
function fetchJson(u) {
|
||
return fetch(u, { cache: 'no-store' }).then(function (r) {
|
||
if (!r.ok) throw new Error('HTTP ' + r.status + ' for ' + u);
|
||
return r.json();
|
||
});
|
||
}
|
||
// HTML-escape user-supplied strings before they land in innerHTML.
|
||
// Species names come from BirdNET-Pi's labels file, which is
|
||
// user-editable (custom species lists, l18n) - so they're untrusted.
|
||
function esc(s) {
|
||
return String(s == null ? '' : s)
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
// ---- State ----
|
||
var DATA = { recent: null, lifelist: null };
|
||
var MASKS = null, DIMS = null;
|
||
var currentHours = +readLS('av:window', '24') || 24;
|
||
|
||
// ---- Boot ----
|
||
Promise.all([
|
||
fetchJson('./masks.json').then(function (j) { MASKS = j; }),
|
||
fetchJson('./dims.json').then(function (j) { DIMS = j; }),
|
||
]).then(function () {
|
||
bindUI();
|
||
refreshAll();
|
||
setInterval(refreshRecent, 60000);
|
||
}).catch(function (e) {
|
||
console.error('boot failed', e);
|
||
var c = document.getElementById('collage');
|
||
if (c) c.innerHTML = '<p class="empty">collage failed to load: ' + esc(e.message) + '</p>';
|
||
});
|
||
|
||
// ---- 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;
|
||
pill.style.width = active.offsetWidth + 'px';
|
||
pill.style.transform = 'translateX(' + active.offsetLeft + 'px)';
|
||
}
|
||
function syncAllPills() {
|
||
[$('slider'), $('winPick'), $('atlasSort')].forEach(syncPill);
|
||
}
|
||
function bindUI() {
|
||
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');
|
||
});
|
||
var i = +b.dataset.i;
|
||
views.forEach(function (v) { v.hidden = +v.dataset.view !== i; });
|
||
syncPill(slider);
|
||
if (i === 0) renderCollageFromData();
|
||
if (i === 1) drawHistograms();
|
||
if (i === 2) renderAtlas();
|
||
});
|
||
});
|
||
|
||
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) {
|
||
x.setAttribute('aria-current', x === b ? 'true' : 'false');
|
||
});
|
||
currentHours = +b.dataset.h;
|
||
writeLS('av:window', String(currentHours));
|
||
syncPill(winPick);
|
||
refreshRecent();
|
||
});
|
||
});
|
||
|
||
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');
|
||
});
|
||
writeLS('av:atlasSort', b.dataset.sort);
|
||
syncPill(atlasEl);
|
||
renderAtlas();
|
||
});
|
||
});
|
||
|
||
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'); });
|
||
});
|
||
|
||
var rT;
|
||
window.addEventListener('resize', function () {
|
||
clearTimeout(rT);
|
||
rT = setTimeout(function () {
|
||
syncAllPills();
|
||
renderCollageFromData();
|
||
drawHistograms();
|
||
}, 120);
|
||
});
|
||
setTimeout(syncAllPills, 60);
|
||
}
|
||
|
||
// ---- Data fetch ----
|
||
function refreshAll() {
|
||
var h = currentHours;
|
||
return Promise.all([
|
||
fetchJson(api('lifelist')).catch(function () { return null; }),
|
||
fetchJson(api('recent', 'hours=' + h)).catch(function () { return null; }),
|
||
]).then(function (parts) {
|
||
DATA.lifelist = parts[0];
|
||
if (parts[1] && h === currentHours) DATA.recent = parts[1];
|
||
renderCollageFromData();
|
||
drawHistograms();
|
||
});
|
||
}
|
||
function refreshRecent() {
|
||
var h = currentHours;
|
||
return fetchJson(api('recent', 'hours=' + h)).then(function (j) {
|
||
if (h !== currentHours) return;
|
||
DATA.recent = j;
|
||
renderCollageFromData();
|
||
drawHistograms();
|
||
}).catch(function () {});
|
||
}
|
||
|
||
// ---- 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;
|
||
var bytes = atob(rec.bits);
|
||
var w = rec.w, h = rec.h;
|
||
var cells = [];
|
||
for (var y = 0; y < h; y++) {
|
||
for (var x = 0; x < w; x++) {
|
||
var i = y * w + x;
|
||
var b = bytes.charCodeAt(i >> 3);
|
||
if ((b >> (7 - (i & 7))) & 1) cells.push([x, y]);
|
||
}
|
||
}
|
||
return (maskCache[slug] = { w: w, h: h, cells: cells });
|
||
}
|
||
function slugify(sci) {
|
||
return sci.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||
}
|
||
function aspect(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) ----
|
||
function tuning(n) {
|
||
return {
|
||
packingBudgetFrac: n <= 4 ? 0.46 : n <= 12 ? 0.40 : n <= 24 ? 0.34 : 0.28,
|
||
countExp: 0.65,
|
||
minTileAreaFrac: n <= 8 ? 0.0100 : n <= 20 ? 0.0075 : 0.0055,
|
||
ellipseAspectBias: 2.1,
|
||
};
|
||
}
|
||
var GRID_STRIDE = 4;
|
||
|
||
function maskPack(tiles, W, H, ellipseBias) {
|
||
var GW = Math.ceil(W / GRID_STRIDE) + 2;
|
||
var GH = Math.ceil(H / GRID_STRIDE) + 2;
|
||
var grid = new Uint8Array(GW * GH);
|
||
|
||
function cellRange(t, tx, ty, c) {
|
||
var sx = t.fullW / t.mask.w, sy = t.fullH / t.mask.h;
|
||
var x0 = (tx + c[0] * sx) / GRID_STRIDE | 0;
|
||
var y0 = (ty + c[1] * sy) / GRID_STRIDE | 0;
|
||
var x1 = (tx + (c[0] + 1) * sx) / GRID_STRIDE | 0;
|
||
var y1 = (ty + (c[1] + 1) * sy) / GRID_STRIDE | 0;
|
||
if (x0 < 0) x0 = 0; if (y0 < 0) y0 = 0;
|
||
if (x1 >= GW) x1 = GW - 1; if (y1 >= GH) y1 = GH - 1;
|
||
return [x0, y0, x1, y1];
|
||
}
|
||
function collides(t, tx, ty) {
|
||
var cs = t.mask.cells;
|
||
for (var i = 0; i < cs.length; i++) {
|
||
var r = cellRange(t, tx, ty, cs[i]);
|
||
for (var gy = r[1]; gy <= r[3]; gy++) {
|
||
var off = gy * GW;
|
||
for (var gx = r[0]; gx <= r[2]; gx++) if (grid[off + gx]) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
function stamp(t, tx, ty) {
|
||
var cs = t.mask.cells;
|
||
for (var i = 0; i < cs.length; i++) {
|
||
var r = cellRange(t, tx, ty, cs[i]);
|
||
for (var gy = r[1]; gy <= r[3]; gy++) {
|
||
var off = gy * GW;
|
||
for (var gx = r[0]; gx <= r[2]; gx++) grid[off + gx] = 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
var rand = mulberry32(0x9E3779B1);
|
||
var placed = [];
|
||
var comX = W / 2, comY = H / 2;
|
||
tiles.sort(function (a, b) { return (b.fullW * b.fullH) - (a.fullW * a.fullH); });
|
||
for (var ti = 0; ti < tiles.length; ti++) {
|
||
var t = tiles[ti];
|
||
var bestCost = Infinity, best = null;
|
||
var rings = Math.max(W, H);
|
||
for (var r = 0; r < rings; r += GRID_STRIDE * 2) {
|
||
var step = Math.max(1, r * 0.05);
|
||
var samples = r === 0 ? 1 : Math.max(8, Math.floor(r * 0.6));
|
||
for (var s = 0; s < samples; s++) {
|
||
var ang = (s / samples) * Math.PI * 2;
|
||
var px = comX + Math.cos(ang) * r * ellipseBias - t.fullW / 2;
|
||
var py = comY + Math.sin(ang) * r - t.fullH / 2;
|
||
if (collides(t, px, py)) continue;
|
||
var dxx = (px + t.fullW / 2 - comX);
|
||
var dyy = (py + t.fullH / 2 - comY);
|
||
var cost = Math.hypot(dxx / ellipseBias, dyy) + rand() * step * 0.5;
|
||
if (cost < bestCost) { bestCost = cost; best = { x: px, y: py }; }
|
||
}
|
||
if (best && r > 0) break;
|
||
}
|
||
if (best) { t.x = best.x; t.y = best.y; stamp(t, best.x, best.y); placed.push(t); }
|
||
else { t.x = -99999; t.y = -99999; placed.push(t); }
|
||
}
|
||
return placed;
|
||
}
|
||
function mulberry32(a) {
|
||
return function () {
|
||
var t = a += 0x6D2B79F5;
|
||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
|
||
function renderCollageFromData() {
|
||
renderCollage((DATA.recent && DATA.recent.species) || []);
|
||
}
|
||
function renderCollage(items) {
|
||
var collage = $('collage');
|
||
if (!collage) return;
|
||
collage.innerHTML = '';
|
||
if (!items.length) {
|
||
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;
|
||
if (!W || !H) { setTimeout(function () { renderCollage(items); }, 80); return; }
|
||
|
||
var T = tuning(items.length);
|
||
var vpArea = W * H;
|
||
var budget = vpArea * T.packingBudgetFrac;
|
||
var minArea = vpArea * T.minTileAreaFrac;
|
||
|
||
var tiles = items.map(function (s) {
|
||
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);
|
||
if (sumA > budget) {
|
||
var fixedSum = tiles.filter(function (t) { return t.area <= minArea + 1e-9; })
|
||
.reduce(function (a, t) { return a + t.area; }, 0);
|
||
var flexSum = sumA - fixedSum;
|
||
var flexBudget = Math.max(0, budget - fixedSum);
|
||
var shrink = flexSum > 0 ? Math.min(1, flexBudget / flexSum) : 1;
|
||
tiles.forEach(function (t) { if (t.area > minArea + 1e-9) t.area *= shrink; });
|
||
}
|
||
tiles.forEach(function (t) {
|
||
t.fullW = Math.sqrt(t.area * t.ar);
|
||
t.fullH = t.fullW / t.ar;
|
||
});
|
||
|
||
var placed = maskPack(tiles, W, H, T.ellipseAspectBias);
|
||
function bounds(a) {
|
||
var L = Infinity, R = -Infinity, T2 = Infinity, B = -Infinity;
|
||
a.forEach(function (t) {
|
||
if (t.x < -1000) return;
|
||
if (t.x < L) L = t.x;
|
||
if (t.x + t.fullW > R) R = t.x + t.fullW;
|
||
if (t.y < T2) T2 = t.y;
|
||
if (t.y + t.fullH > B) B = t.y + t.fullH;
|
||
});
|
||
return { L: L, R: R, T: T2, B: B };
|
||
}
|
||
var b = bounds(placed);
|
||
for (var it = 0; it < 10; it++) {
|
||
var miss = placed.some(function (t) { return t.x < -1000; });
|
||
var over = b.L < 0 || b.T < 0 || b.R > W || b.B > H;
|
||
if (!miss && !over) break;
|
||
var scale = 0.93;
|
||
if (over) {
|
||
var clW = b.R - b.L, clH = b.B - b.T;
|
||
var sx = (W * 0.96) / Math.max(clW, W * 0.96);
|
||
var sy = (H * 0.94) / Math.max(clH, H * 0.94);
|
||
scale = Math.min(scale, sx, sy);
|
||
}
|
||
tiles.forEach(function (t) { t.fullW *= scale; t.fullH *= scale; });
|
||
placed = maskPack(tiles, W, H, T.ellipseAspectBias);
|
||
b = bounds(placed);
|
||
}
|
||
var dx = W / 2 - (b.L + b.R) / 2, dy = H / 2 - (b.T + b.B) / 2;
|
||
if (Math.abs(dx) > 1 || Math.abs(dy) > 1) {
|
||
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 label = s.com || s.sci;
|
||
var btn = document.createElement('button');
|
||
btn.className = 'gtile';
|
||
btn.type = 'button';
|
||
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';
|
||
var img = document.createElement('img');
|
||
img.loading = 'lazy';
|
||
img.decoding = 'async';
|
||
img.alt = label;
|
||
img.src = imgUrl(s.sci, s.com);
|
||
btn.appendChild(img);
|
||
btn.addEventListener('click', function () { openDetail(s); });
|
||
collage.appendChild(btn);
|
||
});
|
||
}
|
||
|
||
// ---- Stats ----
|
||
function drawHistograms() {
|
||
var tl = $('statsTimeline');
|
||
if (!tl) return;
|
||
var sp = ((DATA.recent && DATA.recent.species) || []).slice();
|
||
if (!sp.length) { tl.innerHTML = '<div class="stats-tl-empty">no detections in this window</div>'; return; }
|
||
var now = Date.now();
|
||
var windowStart = currentHours >= 1000000 ? now - 90 * 24 * 3600000 : now - currentHours * 3600000;
|
||
var windowSpan = Math.max(1, now - windowStart);
|
||
sp.sort(function (a, b) { return (+b.n || 0) - (+a.n || 0); });
|
||
var W = tl.clientWidth || window.innerWidth;
|
||
var cap = Math.max(4, Math.floor(W / 28));
|
||
if (sp.length > cap) sp = sp.slice(0, cap);
|
||
var maxN = sp.reduce(function (m, s) { return Math.max(m, +s.n || 0); }, 1);
|
||
tl.innerHTML = '';
|
||
var plot = document.createElement('div');
|
||
plot.className = 'stats-tl-plot';
|
||
sp.forEach(function (s) {
|
||
var ts = Date.parse((s.last_seen || '').replace(' ', 'T'));
|
||
var leftPct = isNaN(ts) ? 50 : ((Math.max(windowStart, Math.min(now, ts)) - windowStart) / windowSpan) * 100;
|
||
var n = +s.n || 0;
|
||
var bottomPct = (n / maxN) * 50;
|
||
var mark = document.createElement('div');
|
||
mark.className = 'stats-tl-mark';
|
||
mark.style.left = leftPct.toFixed(1) + '%';
|
||
mark.style.bottom = bottomPct.toFixed(1) + '%';
|
||
mark.dataset.sci = s.sci;
|
||
mark.title = (s.com || s.sci) + ' · ' + n + ' calls';
|
||
plot.appendChild(mark);
|
||
});
|
||
tl.appendChild(plot);
|
||
}
|
||
|
||
// ---- Atlas ----
|
||
function renderAtlas() {
|
||
var list = $('atlasList');
|
||
if (!list) return;
|
||
var sp = ((DATA.lifelist && DATA.lifelist.species) || []).slice();
|
||
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) {
|
||
return Date.parse((b.last_seen || '').replace(' ', 'T')) - Date.parse((a.last_seen || '').replace(' ', 'T'));
|
||
});
|
||
else sp.sort(function (a, b) {
|
||
return Date.parse((a.first_seen || '').replace(' ', 'T')) - Date.parse((b.first_seen || '').replace(' ', 'T'));
|
||
});
|
||
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 ----
|
||
function openDetail(s) {
|
||
var sci = s.sci || s;
|
||
var rec = ((DATA.lifelist && DATA.lifelist.species) || []).find(function (x) { return x.sci === sci; }) || s;
|
||
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 =
|
||
'<div class="modal-backdrop" data-close="1"></div>' +
|
||
'<div class="detail-card">' +
|
||
' <button type="button" class="modal-close" data-close="1" aria-label="close">×</button>' +
|
||
' <img class="detail-img" alt="">' +
|
||
' <h2 class="detail-title"></h2>' +
|
||
' <p class="detail-sci"><em></em></p>' +
|
||
' <p class="detail-stats"></p>' +
|
||
' <audio class="detail-audio" controls></audio>' +
|
||
' <img class="detail-spec" alt="spectrogram">' +
|
||
'</div>';
|
||
var card = modal.querySelector('.detail-card');
|
||
card.querySelector('.detail-img').src = imgUrl(sci, rec.com);
|
||
card.querySelector('.detail-img').alt = label;
|
||
card.querySelector('.detail-title').textContent = label;
|
||
card.querySelector('.detail-sci em').textContent = sci;
|
||
card.querySelector('.detail-stats').textContent =
|
||
(+rec.n || 0) + ' calls · last heard ' + (rec.last_seen || '-');
|
||
card.querySelector('.detail-audio').src = media('recording.php', 'sci=' + encodeURIComponent(sci));
|
||
card.querySelector('.detail-spec').src = media('spectrogram.php', 'sci=' + encodeURIComponent(sci));
|
||
modal.setAttribute('aria-hidden', 'false');
|
||
modal.querySelectorAll('[data-close]').forEach(function (el) {
|
||
el.addEventListener('click', function () { modal.setAttribute('aria-hidden', 'true'); });
|
||
});
|
||
}
|
||
})();
|