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:
+158
-73
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user