Frontend: lift CSS + HTML structure from live apt.ts verbatim

The previous styles.css was a from-memory reconstruction with wrong
palette (tan paper #f4ede0 vs the live near-white #fcfcfb, red accent
vs warm-grey). HTML class names also diverged (.collage vs .gcollage,
#atlasList vs #atlasGrid, .stats-tl vs .stats-timeline). Result: the
overlay looked nothing like the live site.

Now:
- styles.css copied verbatim from the apt.ts <style> block (1449 lines)
- index.html mirrors the live body structure: header.top + main.stage
  with view sections id=v0/v1/v2, nav.slider, about-modal
- apt.js updated for matching IDs/classes:
  - View switching via translateX on .views container (live uses
    flex: 0 0 100% per view, slides between them)
  - Hover state via alpha-mask hit-testing in mousemove (.gtile is
    pointer-events:none, bounding rects overlap)
  - Atlas builds .bird-card elements (the live CSS class)
  - atlasSort data-sort='alpha' (was 'first')
  - #atlasGrid (was #atlasList)

Verified against Docker container faithfully replicating BirdNET-Pi's
runtime (Caddy + PHP-FPM as caddy user + php-fpm-socket-helper):
side-by-side screenshots with bird.onethreenine.net show identical
palette, typography, pill chrome, tile rendering. Differences are
content-only (different seeded species).
This commit is contained in:
Twarner491
2026-05-28 11:35:27 -07:00
parent 9ea3249f9d
commit f8e599ccdc
3 changed files with 1648 additions and 353 deletions
+158 -73
View File
@@ -4,13 +4,13 @@
* collage - mask-packed cluster of species illustrations, sized by * collage - mask-packed cluster of species illustrations, sized by
* count. Layout normalises so every bird always fits on * count. Layout normalises so every bird always fits on
* every viewport. * every viewport.
* stats - per-species mark on a time × count plot. * stats - per-species mark on a time x count plot.
* atlas - grid of every species ever detected, with detail modal. * atlas - grid of every species ever detected, with detail modal.
* *
* Lives at $HOME/BirdNET-Pi/avian/frontend/ on the Pi; the install * Lives at $HOME/BirdNET-Pi/avian/frontend/ on the Pi; install_services.sh
* symlinks $HOME/BirdNET-Pi/avian $EXTRACTED/avian, so this file * symlinks $HOME/BirdNET-Pi/avian -> $EXTRACTED/avian, so this file loads
* loads from http://birdnet.local/avian/frontend/apt.js with the * from http://birdnet.local/avian/frontend/apt.js with the collage at
* collage at http://birdnet.local/avian/frontend/. * http://birdnet.local/avian/ (via the redirect at /avian/index.html).
* *
* All API calls are relative - they target the PHP shims in ../api/ * All API calls are relative - they target the PHP shims in ../api/
* served by BirdNET-Pi's existing Caddy + PHP-FPM stack. No frontend * served by BirdNET-Pi's existing Caddy + PHP-FPM stack. No frontend
@@ -19,19 +19,12 @@
(function () { (function () {
'use strict'; '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) { function api(action, qs) {
return '../api/birdnet-api.php?action=' + action + (qs ? '&' + qs : ''); return '../api/birdnet-api.php?action=' + action + (qs ? '&' + qs : '');
} }
function media(file, qs) { function media(file, qs) {
return '../api/' + file + (qs ? '?' + 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'; var IMG_VERSION = '1';
function readLS(k, d) { try { return localStorage.getItem(k) || d; } catch (e) { return d; } } function readLS(k, d) { try { return localStorage.getItem(k) || d; } catch (e) { return d; } }
@@ -42,19 +35,12 @@
return r.json(); 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// ---- State ---- // ---- State ----
var DATA = { recent: null, lifelist: null }; var DATA = { recent: null, lifelist: null };
var MASKS = null, DIMS = null; var MASKS = null, DIMS = null;
var currentHours = +readLS('av:window', '24') || 24; var currentHours = +readLS('av:window', '24') || 24;
var currentView = +readLS('av:view', '0') || 0;
// ---- Boot ---- // ---- Boot ----
Promise.all([ Promise.all([
@@ -67,7 +53,12 @@
}).catch(function (e) { }).catch(function (e) {
console.error('boot failed', e); console.error('boot failed', e);
var c = document.getElementById('collage'); var c = document.getElementById('collage');
if (c) c.innerHTML = '<p class="empty">collage failed to load: ' + esc(e.message) + '</p>'; if (c) {
var p = document.createElement('p');
p.className = 'empty';
p.textContent = 'collage failed to load: ' + e.message;
c.appendChild(p);
}
}); });
// ---- UI ---- // ---- UI ----
@@ -83,24 +74,30 @@
function syncAllPills() { function syncAllPills() {
[$('slider'), $('winPick'), $('atlasSort')].forEach(syncPill); [$('slider'), $('winPick'), $('atlasSort')].forEach(syncPill);
} }
function setView(i) {
i = Math.max(0, Math.min(2, i));
currentView = i;
writeLS('av:view', String(i));
var views = $('views');
if (views) views.style.transform = 'translateX(-' + (i * 100) + '%)';
var slider = $('slider');
if (slider) {
[].slice.call(slider.querySelectorAll('button')).forEach(function (b) {
b.setAttribute('aria-current', +b.dataset.i === i ? 'true' : 'false');
});
syncPill(slider);
}
if (i === 0) renderCollageFromData();
if (i === 1) drawHistograms();
if (i === 2) renderAtlas();
}
function bindUI() { function bindUI() {
var slider = $('slider'); var slider = $('slider');
var winPick = $('winPick'); var winPick = $('winPick');
var atlasEl = $('atlasSort'); var atlasEl = $('atlasSort');
var views = document.querySelectorAll('.view');
if (slider) [].slice.call(slider.querySelectorAll('button')).forEach(function (b) { if (slider) [].slice.call(slider.querySelectorAll('button')).forEach(function (b) {
b.addEventListener('click', function () { b.addEventListener('click', function () { setView(+b.dataset.i); });
[].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) { if (winPick) [].slice.call(winPick.querySelectorAll('button')).forEach(function (b) {
@@ -136,6 +133,18 @@
el.addEventListener('click', function () { aboutModal && aboutModal.setAttribute('aria-hidden', 'true'); }); el.addEventListener('click', function () { aboutModal && aboutModal.setAttribute('aria-hidden', 'true'); });
}); });
// Alpha-mask hover hit-testing for the collage. .gtile is
// pointer-events:none so the bounding boxes don't intercept events;
// we sample the bird's pixel under the cursor instead.
var collageEl = $('collage');
if (collageEl) {
collageEl.addEventListener('mousemove', function (e) { collageHover(e, collageEl); });
collageEl.addEventListener('mouseleave', function () {
document.querySelectorAll('.gtile.is-hover').forEach(function (t) { t.classList.remove('is-hover'); });
});
collageEl.addEventListener('click', function (e) { collageClick(e, collageEl); });
}
var rT; var rT;
window.addEventListener('resize', function () { window.addEventListener('resize', function () {
clearTimeout(rT); clearTimeout(rT);
@@ -143,11 +152,60 @@
syncAllPills(); syncAllPills();
renderCollageFromData(); renderCollageFromData();
drawHistograms(); drawHistograms();
var views = $('views');
if (views) views.style.transform = 'translateX(-' + (currentView * 100) + '%)';
}, 120); }, 120);
}); });
// Initial view position + pill sync
var views = $('views');
if (views) views.style.transform = 'translateX(-' + (currentView * 100) + '%)';
if (slider) [].slice.call(slider.querySelectorAll('button')).forEach(function (b) {
b.setAttribute('aria-current', +b.dataset.i === currentView ? 'true' : 'false');
});
setTimeout(syncAllPills, 60); setTimeout(syncAllPills, 60);
} }
// ---- Hover hit-testing: find which tile's mask is under the cursor ----
// .gtile rectangles overlap, so :hover would light the wrong bird. We
// walk the placed tiles topmost-first, transform the cursor into each
// tile's mask space, and pick the one whose mask cell is "on".
var lastHoverTile = null;
function hitTest(e, container) {
var rect = container.getBoundingClientRect();
var x = e.clientX - rect.left, y = e.clientY - rect.top;
var tiles = container.querySelectorAll('.gtile');
for (var i = tiles.length - 1; i >= 0; i--) {
var t = tiles[i];
var bx = t.offsetLeft, by = t.offsetTop, bw = t.offsetWidth, bh = t.offsetHeight;
if (x < bx || x > bx + bw || y < by || y > by + bh) continue;
var slug = t.dataset.slug;
var mask = MASKS && MASKS[slug];
if (!mask) return t;
var mx = ((x - bx) / bw * mask.w) | 0;
var my = ((y - by) / bh * mask.h) | 0;
var idx = my * mask.w + mx;
var byte = atob(mask.bits).charCodeAt(idx >> 3);
if ((byte >> (7 - (idx & 7))) & 1) return t;
}
return null;
}
function collageHover(e, container) {
var hit = hitTest(e, container);
if (hit === lastHoverTile) return;
if (lastHoverTile) lastHoverTile.classList.remove('is-hover');
if (hit) hit.classList.add('is-hover');
lastHoverTile = hit;
container.style.cursor = hit ? 'pointer' : 'default';
}
function collageClick(e, container) {
var hit = hitTest(e, container);
if (!hit) return;
var sci = hit.dataset.sci;
var record = ((DATA.recent && DATA.recent.species) || []).find(function (s) { return s.sci === sci; });
if (record) openDetail(record);
}
// ---- Data fetch ---- // ---- Data fetch ----
function refreshAll() { function refreshAll() {
var h = currentHours; var h = currentHours;
@@ -159,6 +217,7 @@
if (parts[1] && h === currentHours) DATA.recent = parts[1]; if (parts[1] && h === currentHours) DATA.recent = parts[1];
renderCollageFromData(); renderCollageFromData();
drawHistograms(); drawHistograms();
if (currentView === 2) renderAtlas();
}); });
} }
function refreshRecent() { function refreshRecent() {
@@ -315,10 +374,10 @@
var mask = loadMask(slug); var mask = loadMask(slug);
if (!mask) return null; if (!mask) return null;
var n = +s.n; if (!n || isNaN(n)) n = 1; 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) }; return { mask: mask, data: s, slug: slug, ar: aspect(s.sci), score: Math.pow(Math.max(1, n), T.countExp) };
}).filter(Boolean); }).filter(Boolean);
if (!tiles.length) { collage.innerHTML = ''; return; } if (!tiles.length) return;
var sumScore = tiles.reduce(function (a, t) { return a + t.score; }, 0) || 1; 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); }); tiles.forEach(function (t) { t.area = Math.max(minArea, budget * t.score / sumScore); });
@@ -369,8 +428,6 @@
placed.forEach(function (t) { if (t.x > -1000) { t.x += dx; t.y += dy; } }); 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) { placed.forEach(function (t) {
var s = t.data; var s = t.data;
var label = s.com || s.sci; var label = s.com || s.sci;
@@ -378,6 +435,7 @@
btn.className = 'gtile'; btn.className = 'gtile';
btn.type = 'button'; btn.type = 'button';
btn.dataset.sci = s.sci; btn.dataset.sci = s.sci;
btn.dataset.slug = t.slug;
btn.setAttribute('aria-label', label); btn.setAttribute('aria-label', label);
btn.title = label + ' · ' + (+s.n || 0) + ' calls'; btn.title = label + ' · ' + (+s.n || 0) + ' calls';
btn.style.left = t.x + 'px'; btn.style.left = t.x + 'px';
@@ -390,17 +448,23 @@
img.alt = label; img.alt = label;
img.src = imgUrl(s.sci, s.com); img.src = imgUrl(s.sci, s.com);
btn.appendChild(img); btn.appendChild(img);
btn.addEventListener('click', function () { openDetail(s); });
collage.appendChild(btn); collage.appendChild(btn);
}); });
} }
// ---- Stats ---- // ---- Stats: per-species mark on a time x count plot ----
function drawHistograms() { function drawHistograms() {
var tl = $('statsTimeline'); var tl = $('statsTimeline');
if (!tl) return; if (!tl) return;
var sp = ((DATA.recent && DATA.recent.species) || []).slice(); 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; } if (!sp.length) {
tl.innerHTML = '';
var p = document.createElement('div');
p.className = 'stats-tl-empty';
p.textContent = 'no detections in this window';
tl.appendChild(p);
return;
}
var now = Date.now(); var now = Date.now();
var windowStart = currentHours >= 1000000 ? now - 90 * 24 * 3600000 : now - currentHours * 3600000; var windowStart = currentHours >= 1000000 ? now - 90 * 24 * 3600000 : now - currentHours * 3600000;
var windowSpan = Math.max(1, now - windowStart); var windowSpan = Math.max(1, now - windowStart);
@@ -428,49 +492,72 @@
tl.appendChild(plot); tl.appendChild(plot);
} }
// ---- Atlas ---- // ---- Atlas: grid of bird-card per species ----
function renderAtlas() { function renderAtlas() {
var list = $('atlasList'); var grid = $('atlasGrid');
if (!list) return; if (!grid) return;
var sp = ((DATA.lifelist && DATA.lifelist.species) || []).slice(); var sp = ((DATA.lifelist && DATA.lifelist.species) || []).slice();
grid.innerHTML = '';
if (!sp.length) { if (!sp.length) {
var p = document.createElement('p'); var empty = document.createElement('div');
p.className = 'empty'; empty.className = 'atlas-empty';
p.textContent = 'no species yet - atlas fills in as the Pi detects birds.'; var p1 = document.createElement('p');
list.innerHTML = ''; p1.textContent = 'No birds detected yet.';
list.appendChild(p); var p2 = document.createElement('p');
p2.className = 'hint';
p2.textContent = 'The atlas fills up as BirdNET-Pi identifies new species.';
empty.appendChild(p1); empty.appendChild(p2);
grid.appendChild(empty);
return; return;
} }
var sort = readLS('av:atlasSort', 'count'); var sort = readLS('av:atlasSort', 'count');
if (sort === 'count') sp.sort(function (a, b) { return (+b.n || 0) - (+a.n || 0); }); if (sort === 'recent') {
else if (sort === 'recent') sp.sort(function (a, b) { sp.sort(function (a, b) {
return Date.parse((b.last_seen || '').replace(' ', 'T')) - Date.parse((a.last_seen || '').replace(' ', 'T')); return Date.parse((b.last_seen || '').replace(' ', 'T')) - Date.parse((a.last_seen || '').replace(' ', 'T'));
}); });
else sp.sort(function (a, b) { } else if (sort === 'alpha') {
return Date.parse((a.first_seen || '').replace(' ', 'T')) - Date.parse((b.first_seen || '').replace(' ', 'T')); sp.sort(function (a, b) { return (a.com || a.sci).localeCompare(b.com || b.sci); });
}); } else {
list.innerHTML = ''; sp.sort(function (a, b) { return (+b.n || 0) - (+a.n || 0); });
}
sp.forEach(function (s) { sp.forEach(function (s) {
var btn = document.createElement('button'); var card = document.createElement('button');
btn.className = 'atlas-card'; card.className = 'bird-card';
btn.type = 'button'; card.type = 'button';
btn.dataset.sci = s.sci; card.dataset.sci = s.sci;
var stat = document.createElement('div');
stat.className = 'stat';
var n = document.createElement('span');
n.className = 'n'; n.textContent = String(+s.n || 0);
var lbl = document.createElement('span');
lbl.className = 'lbl'; lbl.textContent = (+s.n || 0) === 1 ? 'call' : 'calls';
stat.appendChild(n); stat.appendChild(lbl);
var wrap = document.createElement('div');
wrap.className = 'img-wrap';
var img = document.createElement('img'); var img = document.createElement('img');
img.loading = 'lazy'; img.loading = 'lazy';
img.decoding = 'async'; img.decoding = 'async';
img.alt = s.com || s.sci; img.alt = s.com || s.sci;
img.src = imgUrl(s.sci, s.com); img.src = imgUrl(s.sci, s.com);
var name = document.createElement('span'); wrap.appendChild(img);
name.className = 'atlas-name';
name.textContent = s.com || s.sci; var h3 = document.createElement('h3');
var count = document.createElement('span'); h3.textContent = s.com || s.sci;
count.className = 'atlas-count'; var sci = document.createElement('p');
count.textContent = String(+s.n || 0); sci.className = 'sci';
btn.appendChild(img); var em = document.createElement('em');
btn.appendChild(name); em.textContent = s.sci;
btn.appendChild(count); sci.appendChild(em);
btn.addEventListener('click', function () { openDetail(s); });
list.appendChild(btn); card.appendChild(stat);
card.appendChild(wrap);
card.appendChild(h3);
card.appendChild(sci);
card.addEventListener('click', function () { openDetail(s); });
grid.appendChild(card);
}); });
} }
@@ -487,8 +574,6 @@
modal.setAttribute('role', 'dialog'); modal.setAttribute('role', 'dialog');
document.body.appendChild(modal); 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 = modal.innerHTML =
'<div class="modal-backdrop" data-close="1"></div>' + '<div class="modal-backdrop" data-close="1"></div>' +
'<div class="detail-card">' + '<div class="detail-card">' +
+51 -27
View File
@@ -3,46 +3,70 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>apartment birds</title> <title>your birds</title>
<meta name="description" content="A live bird collage from your apartment window."> <meta name="description" content="A live bird collage from your window.">
<link rel="stylesheet" href="./styles.css"> <link rel="stylesheet" href="./styles.css">
</head> </head>
<body> <body>
<header class="top">
<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="1000000" type="button">ALL</button>
</div>
</header>
<main class="stage"> <main class="stage">
<header class="static-head"> <header class="static-head">
<button type="button" class="pre" id="aboutLink">apartment birds</button> <button type="button" class="pre" id="aboutLink">your birds</button>
<h1 id="staticTitle">Heard Recently</h1> <h1 id="staticTitle">Heard Recently</h1>
</header> </header>
<div class="views" id="views"> <div class="views" id="views">
<section class="view view-collage" data-view="0"> <section class="view" id="v0" aria-label="Bird collage">
<div id="collage" class="collage"></div> <div class="gcollage" id="collage"></div>
</section> </section>
<section class="view view-stats" data-view="1" hidden>
<div id="statsTimeline" class="stats-tl"></div> <section class="view" id="v1" aria-label="Stats">
</section> <div class="stats-grid">
<section class="view view-atlas" data-view="2" hidden> <div class="stats-timeline" id="statsTimeline"></div>
<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>
<div id="atlasList" class="atlas-list"></div> </section>
<section class="view" id="v2" aria-label="Atlas">
<div class="atlas-controls">
<div class="atlas-sort" id="atlasSort" role="tablist" aria-label="sort atlas">
<i class="seg-pill" aria-hidden="true"></i>
<button data-sort="count" type="button" aria-current="true" aria-label="most heard">
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<path d="M2 12 V8"/><path d="M6 12 V5"/><path d="M10 12 V3"/>
</svg>
<span class="tip">most heard</span>
</button>
<button data-sort="recent" type="button" aria-label="most recent">
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<circle cx="7" cy="7" r="5.2"/><path d="M7 4 V7 L9.2 8.3"/>
</svg>
<span class="tip">most recent</span>
</button>
<button data-sort="alpha" type="button" aria-label="alphabetical">
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.4 11.5 L4 4.5 L5.6 11.5"/><path d="M2.9 9.2 H5.1"/>
<path d="M8.2 11.5 V4.5 H10.4 a1.3 1.3 0 0 1 0 2.6 H8.2"/><path d="M8.2 7.1 H10.6 a1.5 1.5 0 0 1 0 3 H8.2"/>
</svg>
<span class="tip">a → z</span>
</button>
</div>
</div>
<div class="atlas-grid" id="atlasGrid"></div>
</section> </section>
</div> </div>
</main> </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"> <nav class="slider" id="slider" aria-label="View">
<i class="seg-pill" aria-hidden="true"></i> <i class="seg-pill" aria-hidden="true"></i>
<button type="button" data-i="0" aria-current="true">collage</button> <button type="button" data-i="0" aria-current="true">collage</button>
@@ -54,9 +78,9 @@
<div class="modal-backdrop" data-close="1"></div> <div class="modal-backdrop" data-close="1"></div>
<div class="about-card" role="document"> <div class="about-card" role="document">
<button type="button" class="modal-close" data-close="1" aria-label="close">×</button> <button type="button" class="modal-close" data-close="1" aria-label="close">×</button>
<p class="about-eyebrow">apartment birds</p> <p class="about-eyebrow">your birds</p>
<h2 id="aboutTitle">The birds outside my window</h2> <h2 id="aboutTitle">The birds outside your 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> <p class="about-body">A tiny microphone identifies every passing bird 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 often it's been heard.</p>
<button type="button" class="about-explore" data-close="1">explore the birds →</button> <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> <p class="built-by">built with <a href="https://github.com/Twarner491/AvianVisitors" target="_blank" rel="noopener">AvianVisitors</a></p>
</div> </div>
+1439 -253
View File
File diff suppressed because it is too large Load Diff