AvianVisitors: BirdNET-Pi collage frontend overlay
Adds avian/ on top of upstream BirdNET-Pi: - frontend/ static collage UI (mask-packed bird tiles, sized by detection count) - assets/ ~450 bundled kachō-e illustrations + cutouts + binary masks - api/ PHP shims for recent/lifelist/firstseen/timeseries JSON - scripts/ installer + Gemini pregen + editable prompt template - caddy/ snippet mounting /collage on existing Caddy - forwarding/ optional HA / MQTT / Cloudflare configs Default install: http://birdnet.local/collage/ with no auth. Upstream BirdNET-Pi README preserved at README.upstream.md.
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
/* AvianVisitors — bird collage frontend.
|
||||
*
|
||||
* Renders three views over BirdNET-Pi detections:
|
||||
* collage — mask-packed cluster of species illustrations, sized by
|
||||
* count. Layout normalises so all birds always fit on
|
||||
* every viewport.
|
||||
* stats — per-species histogram across the selected time window.
|
||||
* atlas — alphabet of all detected species with detail modal.
|
||||
*
|
||||
* Reads four JSON endpoints exposed by the Pi (PHP shims in ../api/):
|
||||
* GET /api/recent.json?hours=N — species + counts in window
|
||||
* GET /api/lifelist.json — all species ever detected
|
||||
* GET /api/firstseen.json — earliest detection per species
|
||||
* GET /api/timeseries.json?days=D — daily counts per species
|
||||
*
|
||||
* Plus two media proxies (also via PHP):
|
||||
* GET /api/img?sci=X[&com=Y] — bird illustration
|
||||
* GET /api/recording?sci=X — most-recent mp3
|
||||
* GET /api/spectrogram?sci=X — matching spectrogram png
|
||||
*
|
||||
* Local-network deploys ship without auth. The PHP shims accept the
|
||||
* X-AvianVisitors-Token header for the optional Cloudflare-forwarded
|
||||
* deployment — see ../forwarding/.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ---- Config ----
|
||||
// API_BASE: where the JSON endpoints live. Default '/' works when
|
||||
// Caddy mounts the frontend at the same host as the API. Override
|
||||
// with window.AV_API_BASE if you split origins.
|
||||
var API_BASE = (window.AV_API_BASE || '').replace(/\/$/, '');
|
||||
var IMG_VERSION = '1'; // bump after running scripts/pregen.py
|
||||
|
||||
function api(p) { return API_BASE + p; }
|
||||
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) { return r.json(); }); }
|
||||
|
||||
// ---- State ----
|
||||
var DATA = {
|
||||
recent: null, // /api/recent.json?hours=N (refetched on picker change)
|
||||
lifelist: null, // /api/lifelist.json
|
||||
firstseen: null, // /api/firstseen.json
|
||||
timeseries: null, // /api/timeseries.json
|
||||
};
|
||||
var MASKS = null, DIMS = null;
|
||||
var currentHours = +readLS('av:window', '24') || 24;
|
||||
|
||||
// ---- Boot: load mask/dim registries, then first data pull ----
|
||||
Promise.all([
|
||||
fetchJson(api('/avian/masks.json')).then(function (j) { MASKS = j; }),
|
||||
fetchJson(api('/avian/dims.json')).then(function (j) { DIMS = j; }),
|
||||
]).then(function () {
|
||||
bindUI();
|
||||
refreshAll();
|
||||
setInterval(refreshRecent, 60000); // poll for new detections every min
|
||||
}).catch(function (e) { console.error('boot failed', e); });
|
||||
|
||||
// ---- UI bindings ----
|
||||
var slider = document.getElementById('slider');
|
||||
var winPick = document.getElementById('winPick');
|
||||
var views = document.querySelectorAll('.view');
|
||||
function syncPill(container) {
|
||||
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, document.getElementById('atlasSort')].forEach(function (c) { if (c) syncPill(c); });
|
||||
}
|
||||
function bindUI() {
|
||||
// View 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();
|
||||
});
|
||||
});
|
||||
// Window picker
|
||||
[].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();
|
||||
});
|
||||
});
|
||||
// Atlas sort
|
||||
var atlasEl = document.getElementById('atlasSort');
|
||||
[].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();
|
||||
});
|
||||
});
|
||||
// About modal
|
||||
document.getElementById('aboutLink').addEventListener('click', function () {
|
||||
document.getElementById('about-modal').setAttribute('aria-hidden', 'false');
|
||||
});
|
||||
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);
|
||||
rT = setTimeout(function () {
|
||||
syncAllPills();
|
||||
renderCollageFromData();
|
||||
drawHistograms();
|
||||
}, 120);
|
||||
});
|
||||
setTimeout(syncAllPills, 60);
|
||||
}
|
||||
|
||||
// ---- Data fetch ----
|
||||
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; }),
|
||||
]).then(function (parts) {
|
||||
DATA.lifelist = parts[0];
|
||||
DATA.firstseen = parts[1];
|
||||
DATA.timeseries = parts[2];
|
||||
if (parts[3] && h === currentHours) DATA.recent = parts[3];
|
||||
renderCollageFromData();
|
||||
drawHistograms();
|
||||
});
|
||||
}
|
||||
function refreshRecent() {
|
||||
var h = currentHours;
|
||||
return fetchJson(api('/api/recent.json?hours=' + h)).then(function (j) {
|
||||
if (h !== currentHours) return;
|
||||
DATA.recent = j;
|
||||
renderCollageFromData();
|
||||
drawHistograms();
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
// ---- Mask + slug helpers ----
|
||||
var maskCache = {};
|
||||
function loadMask(slug) {
|
||||
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[slugify(sci)];
|
||||
return d ? d[0] / d[1] : 1.4;
|
||||
}
|
||||
|
||||
// ---- 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,
|
||||
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() {
|
||||
var items = (DATA.recent && DATA.recent.species) || [];
|
||||
renderCollage(items);
|
||||
}
|
||||
function renderCollage(items) {
|
||||
var collage = document.getElementById('collage');
|
||||
collage.innerHTML = '';
|
||||
if (!items.length) {
|
||||
collage.innerHTML = '<p class="empty">no birds heard in this window.</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);
|
||||
|
||||
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; } });
|
||||
}
|
||||
|
||||
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 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.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 = '<img loading="lazy" decoding="async" src="' + img + '" alt="' + (s.com || s.sci) + '">';
|
||||
btn.addEventListener('click', function () { openDetail(s); });
|
||||
collage.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Stats: per-species histogram with last-seen positioning ----
|
||||
function drawHistograms() {
|
||||
var tl = document.getElementById('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);
|
||||
var html = '<div class="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;
|
||||
html += '<div class="stats-tl-mark" style="left:' + leftPct.toFixed(1) + '%;bottom:' + bottomPct.toFixed(1) + '%" data-sci="' + s.sci + '" title="' + (s.com || s.sci) + ' · ' + n + ' calls"></div>';
|
||||
});
|
||||
html += '</div>';
|
||||
tl.innerHTML = html;
|
||||
}
|
||||
|
||||
// ---- Atlas: gridded alphabet of all species ----
|
||||
function renderAtlas() {
|
||||
var list = document.getElementById('atlasList');
|
||||
if (!list) return;
|
||||
var sp = ((DATA.lifelist && DATA.lifelist.species) || []).slice();
|
||||
if (!sp.length) { list.innerHTML = '<p class="empty">no species yet — atlas fills in as the Pi detects birds.</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.map(function (s) {
|
||||
var img = api('/api/img?sci=' + encodeURIComponent(s.sci) +
|
||||
(s.com ? '&com=' + encodeURIComponent(s.com) : '') +
|
||||
'&v=' + IMG_VERSION);
|
||||
return '<button class="atlas-card" data-sci="' + s.sci + '">' +
|
||||
'<img loading="lazy" decoding="async" src="' + img + '" alt="' + (s.com || s.sci) + '">' +
|
||||
'<span class="atlas-name">' + (s.com || s.sci) + '</span>' +
|
||||
'<span class="atlas-count">' + (+s.n || 0) + '</span>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
[].slice.call(list.querySelectorAll('.atlas-card')).forEach(function (b) {
|
||||
b.addEventListener('click', function () { openDetail({ sci: b.dataset.sci }); });
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Detail modal (basic — opens recording + spectrogram for a species) ----
|
||||
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');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'detail-modal';
|
||||
modal.setAttribute('role', 'dialog');
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
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" src="' + img + '" alt="' + (rec.com || sci) + '">' +
|
||||
' <h2 class="detail-title">' + (rec.com || sci) + '</h2>' +
|
||||
' <p class="detail-sci"><em>' + sci + '</em></p>' +
|
||||
' <p class="detail-stats">' + (+rec.n || 0) + ' calls · last heard ' + (rec.last_seen || '—') + '</p>' +
|
||||
' <audio class="detail-audio" controls src="' + rec_url + '"></audio>' +
|
||||
' <img class="detail-spec" src="' + spec_url + '" alt="spectrogram">' +
|
||||
'</div>';
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
modal.querySelectorAll('[data-close]').forEach(function (el) {
|
||||
el.addEventListener('click', function () { modal.setAttribute('aria-hidden', 'true'); });
|
||||
});
|
||||
}
|
||||
})();
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,68 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<title>apartment birds</title>
|
||||
<meta name="description" content="A live bird collage from your apartment window.">
|
||||
<link rel="icon" type="image/png" href="./favicon.png">
|
||||
<link rel="stylesheet" href="./styles.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<main class="stage">
|
||||
<header class="static-head">
|
||||
<button type="button" class="pre" id="aboutLink">apartment birds</button>
|
||||
<h1 id="staticTitle">Heard Recently</h1>
|
||||
</header>
|
||||
|
||||
<div class="views" id="views">
|
||||
<section class="view view-collage" data-view="0">
|
||||
<div id="collage" class="collage"></div>
|
||||
</section>
|
||||
<section class="view view-stats" data-view="1" hidden>
|
||||
<div id="statsTimeline" class="stats-tl"></div>
|
||||
</section>
|
||||
<section class="view view-atlas" data-view="2" hidden>
|
||||
<div class="atlas-sort" id="atlasSort">
|
||||
<i class="seg-pill" aria-hidden="true"></i>
|
||||
<button type="button" data-sort="count" aria-current="true">most heard</button>
|
||||
<button type="button" data-sort="recent">most recent</button>
|
||||
<button type="button" data-sort="first">first seen</button>
|
||||
</div>
|
||||
<div id="atlasList" class="atlas-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="window-pick" id="winPick" role="tablist">
|
||||
<i class="seg-pill" aria-hidden="true"></i>
|
||||
<button data-h="1" type="button">1H</button>
|
||||
<button data-h="12" type="button">12H</button>
|
||||
<button data-h="24" type="button" aria-current="true">24H</button>
|
||||
<button data-h="168" type="button">7D</button>
|
||||
<button data-h="9999999" type="button">ALL</button>
|
||||
</div>
|
||||
|
||||
<nav class="slider" id="slider" aria-label="View">
|
||||
<i class="seg-pill" aria-hidden="true"></i>
|
||||
<button type="button" data-i="0" aria-current="true">collage</button>
|
||||
<button type="button" data-i="1">stats</button>
|
||||
<button type="button" data-i="2">atlas</button>
|
||||
</nav>
|
||||
|
||||
<div id="about-modal" aria-hidden="true" role="dialog" aria-labelledby="aboutTitle">
|
||||
<div class="modal-backdrop" data-close="1"></div>
|
||||
<div class="about-card" role="document">
|
||||
<button type="button" class="modal-close" data-close="1" aria-label="close">×</button>
|
||||
<p class="about-eyebrow">apartment birds</p>
|
||||
<h2 id="aboutTitle">The birds outside my window</h2>
|
||||
<p class="about-body">A tiny microphone listens for birds passing the window and identifies them with Cornell's <a href="https://birdnet.cornell.edu/" target="_blank" rel="noopener">BirdNET</a>. Each species shows up as an illustration in the collage — sized by how much it's been heard.</p>
|
||||
<button type="button" class="about-explore" data-close="1">explore the birds →</button>
|
||||
<p class="built-by">built with <a href="https://github.com/Twarner491/AvianVisitors" target="_blank" rel="noopener">AvianVisitors</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="./apt.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,263 @@
|
||||
/* AvianVisitors — collage frontend styles.
|
||||
* Paper-and-ink aesthetic to match the bundled kachō-e illustrations. */
|
||||
|
||||
:root {
|
||||
--paper: #f4ede0;
|
||||
--paper-2: #ebe1cf;
|
||||
--ink: #3a2f24;
|
||||
--ink-2: #6b5c4a;
|
||||
--ink-soft: #9d8d76;
|
||||
--accent: #a8341d;
|
||||
--recess: inset 0 1px 2px rgba(58,47,36,0.18), inset 0 -1px 0 rgba(255,255,255,0.4);
|
||||
--raised: 0 1px 0 rgba(58,47,36,0.12), 0 0 0 1px rgba(58,47,36,0.06);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font: 14px/1.5 ui-serif, "Iowan Old Style", Georgia, serif;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
button { font: inherit; color: inherit; }
|
||||
|
||||
/* ---- Stage + static head ---- */
|
||||
.stage {
|
||||
min-height: 100vh;
|
||||
display: flex; flex-direction: column;
|
||||
padding: 40px 16px 120px;
|
||||
}
|
||||
.static-head {
|
||||
text-align: center;
|
||||
padding: 8px 0 14px;
|
||||
}
|
||||
.static-head .pre {
|
||||
background: none; border: 0; padding: 0; cursor: pointer;
|
||||
font: italic 400 13px/1 ui-serif, "Iowan Old Style", Georgia, serif;
|
||||
color: var(--ink-2); letter-spacing: 0.06em;
|
||||
}
|
||||
.static-head .pre:hover { color: var(--ink); }
|
||||
.static-head h1 {
|
||||
margin: 4px 0 0;
|
||||
font: 700 28px/1.1 ui-serif, "Iowan Old Style", Georgia, serif;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
@media (min-width: 768px) { .static-head h1 { font-size: 36px; } }
|
||||
|
||||
/* ---- View container ---- */
|
||||
.views { flex: 1; position: relative; }
|
||||
.view { width: 100%; }
|
||||
|
||||
/* ---- Collage tiles ---- */
|
||||
.collage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: calc(100vh - 240px);
|
||||
min-height: 360px;
|
||||
}
|
||||
.gtile {
|
||||
position: absolute;
|
||||
background: none; border: 0; padding: 0; cursor: pointer;
|
||||
transition: transform 180ms ease, z-index 0s;
|
||||
}
|
||||
.gtile:hover, .gtile.is-hover { transform: scale(1.04); z-index: 3; }
|
||||
.gtile img {
|
||||
width: 100%; height: 100%; object-fit: contain; display: block;
|
||||
pointer-events: none;
|
||||
}
|
||||
.collage .empty {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--ink-soft);
|
||||
font: italic 14px ui-serif, Georgia, serif;
|
||||
}
|
||||
|
||||
/* ---- Stats timeline ---- */
|
||||
.stats-tl {
|
||||
position: relative;
|
||||
width: 100%; height: 60vh; min-height: 340px;
|
||||
padding: 28px 22px 30px;
|
||||
}
|
||||
.stats-tl-plot { position: relative; width: 100%; height: 100%; }
|
||||
.stats-tl-mark {
|
||||
position: absolute; width: 10px; height: 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--ink);
|
||||
transform: translate(-50%, 50%);
|
||||
cursor: pointer;
|
||||
}
|
||||
.stats-tl-empty {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 100%; color: var(--ink-soft); font-style: italic;
|
||||
}
|
||||
|
||||
/* ---- Atlas grid ---- */
|
||||
.atlas-sort {
|
||||
display: flex; position: relative;
|
||||
margin: 0 auto 18px; padding: 4px;
|
||||
background: var(--paper-2); border-radius: 999px;
|
||||
box-shadow: var(--recess);
|
||||
width: fit-content;
|
||||
}
|
||||
.atlas-sort button {
|
||||
position: relative; z-index: 1;
|
||||
background: none; border: 0; padding: 6px 14px; cursor: pointer;
|
||||
font: 10px ui-monospace, Menlo, monospace;
|
||||
letter-spacing: 0.06em; text-transform: lowercase;
|
||||
color: var(--ink-soft);
|
||||
transition: color 160ms;
|
||||
}
|
||||
.atlas-sort button[aria-current="true"] { color: var(--ink); }
|
||||
.atlas-list {
|
||||
display: grid; gap: 14px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
padding: 0 8px;
|
||||
}
|
||||
.atlas-card {
|
||||
background: none; border: 0; padding: 6px; cursor: pointer;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
text-align: center;
|
||||
border-radius: 10px;
|
||||
transition: background 160ms;
|
||||
}
|
||||
.atlas-card:hover { background: rgba(58,47,36,0.04); }
|
||||
.atlas-card img { width: 100%; max-height: 110px; object-fit: contain; }
|
||||
.atlas-name {
|
||||
margin-top: 4px;
|
||||
font: 11px ui-serif, Georgia, serif;
|
||||
color: var(--ink);
|
||||
}
|
||||
.atlas-count {
|
||||
font: 9px ui-monospace, Menlo, monospace;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
/* ---- Bottom-fixed view slider + window picker ---- */
|
||||
.slider, .window-pick {
|
||||
position: fixed;
|
||||
display: flex; padding: 4px;
|
||||
background: var(--paper-2); border-radius: 999px;
|
||||
box-shadow: var(--recess);
|
||||
z-index: 20;
|
||||
}
|
||||
.slider {
|
||||
bottom: 28px; left: 50%; transform: translateX(-50%);
|
||||
}
|
||||
.window-pick {
|
||||
top: 18px; left: 18px;
|
||||
}
|
||||
.slider button, .window-pick button {
|
||||
position: relative; z-index: 1;
|
||||
background: none; border: 0; padding: 7px 14px; cursor: pointer;
|
||||
font: 10px ui-monospace, Menlo, monospace;
|
||||
letter-spacing: 0.08em; text-transform: lowercase;
|
||||
color: var(--ink-soft);
|
||||
transition: color 160ms;
|
||||
}
|
||||
.slider button[aria-current="true"], .window-pick button[aria-current="true"] {
|
||||
color: var(--ink);
|
||||
}
|
||||
.seg-pill {
|
||||
position: absolute; top: 4px; bottom: 4px;
|
||||
background: var(--paper); border-radius: 999px;
|
||||
box-shadow: var(--raised);
|
||||
transition: transform 220ms cubic-bezier(.32,.72,.32,1), width 220ms;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* ---- About modal ---- */
|
||||
#about-modal, #detail-modal {
|
||||
position: fixed; inset: 0; z-index: 60;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 24px;
|
||||
pointer-events: none; opacity: 0;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
#about-modal[aria-hidden="false"], #detail-modal[aria-hidden="false"] {
|
||||
pointer-events: auto; opacity: 1;
|
||||
}
|
||||
.modal-backdrop {
|
||||
position: absolute; inset: 0;
|
||||
background: rgba(26,22,18,0.32);
|
||||
}
|
||||
.modal-close {
|
||||
position: absolute; top: 14px; right: 14px;
|
||||
background: none; border: 0; cursor: pointer;
|
||||
font: 22px/1 ui-serif, Georgia, serif;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.modal-close:hover { color: var(--ink); }
|
||||
.about-card, .detail-card {
|
||||
position: relative;
|
||||
max-width: 432px; width: 100%;
|
||||
background: var(--paper);
|
||||
border-radius: 14px;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255,255,255,0.6),
|
||||
0 30px 80px rgba(26,22,18,0.24);
|
||||
padding: 34px 34px 28px;
|
||||
}
|
||||
.about-card .about-eyebrow {
|
||||
margin: 0 0 7px;
|
||||
font: italic 400 13px/1 ui-serif, Georgia, serif;
|
||||
color: var(--ink-2); letter-spacing: 0.06em;
|
||||
}
|
||||
.about-card h2 {
|
||||
margin: 0 0 13px;
|
||||
font: 700 22px/1.25 ui-serif, Georgia, serif;
|
||||
color: var(--ink);
|
||||
}
|
||||
.about-card .about-body {
|
||||
margin: 0;
|
||||
font: 400 14.5px/1.62 ui-serif, Georgia, serif;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.about-card .about-body a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.about-card .about-explore {
|
||||
margin-top: 20px;
|
||||
background: none; border: 0; padding: 0;
|
||||
font: 700 11px/1 ui-monospace, Menlo, monospace;
|
||||
letter-spacing: 0.08em; text-transform: lowercase;
|
||||
color: var(--ink-soft); cursor: pointer;
|
||||
}
|
||||
.about-card .about-explore:hover { color: var(--ink); }
|
||||
.built-by {
|
||||
margin: 18px 4px 0;
|
||||
font: 10px ui-monospace, Menlo, monospace;
|
||||
color: var(--ink-soft); letter-spacing: 0.04em;
|
||||
}
|
||||
.built-by a {
|
||||
color: inherit; text-decoration: underline; text-underline-offset: 2px;
|
||||
}
|
||||
.built-by a:hover { color: var(--ink); }
|
||||
|
||||
/* ---- Detail modal ---- */
|
||||
.detail-card {
|
||||
max-width: 520px;
|
||||
padding: 26px 30px;
|
||||
}
|
||||
.detail-img {
|
||||
width: 100%; max-height: 220px; object-fit: contain;
|
||||
background: rgba(0,0,0,0.02); border-radius: 8px;
|
||||
}
|
||||
.detail-title {
|
||||
margin: 14px 0 4px;
|
||||
font: 700 22px/1.25 ui-serif, Georgia, serif;
|
||||
}
|
||||
.detail-sci {
|
||||
margin: 0 0 6px;
|
||||
font: 13px ui-serif, Georgia, serif;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.detail-stats {
|
||||
margin: 0 0 14px;
|
||||
font: 11px ui-monospace, Menlo, monospace;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.detail-audio { width: 100%; margin-bottom: 10px; }
|
||||
.detail-spec { width: 100%; border-radius: 6px; background: #111; }
|
||||
Reference in New Issue
Block a user