[FEAT] atlas: spectrogram preview on card play + "lifer" badge
This commit is contained in:
+44
-12
@@ -937,7 +937,7 @@
|
|||||||
var sortMode = (window.__atlasSort) || 'count';
|
var sortMode = (window.__atlasSort) || 'count';
|
||||||
var species = filtered.slice();
|
var species = filtered.slice();
|
||||||
if (sortMode === 'count') {
|
if (sortMode === 'count') {
|
||||||
species.sort(function (a, b) { return (+b.total) - (+a.total); });
|
species.sort(function (a, b) { return (+b.n) - (+a.n); });
|
||||||
} else if (sortMode === 'recent') {
|
} else if (sortMode === 'recent') {
|
||||||
species.sort(function (a, b) {
|
species.sort(function (a, b) {
|
||||||
return (b.last_seen || '').localeCompare(a.last_seen || '');
|
return (b.last_seen || '').localeCompare(a.last_seen || '');
|
||||||
@@ -948,14 +948,21 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A species is a "lifer" in the current view if its all-time first
|
||||||
|
// detection falls inside the selected window - i.e. it was newly added
|
||||||
|
// to the life list this 1h / 12h / 24h / 7d. Never shown for the ALL
|
||||||
|
// window (every species would qualify against an open-ended span).
|
||||||
|
var now = Date.now();
|
||||||
|
var windowStartMs = now - currentHours * 3600000;
|
||||||
grid.innerHTML = species.map(function (s) {
|
grid.innerHTML = species.map(function (s) {
|
||||||
var total = +s.total || 0;
|
var total = +s.n || 0;
|
||||||
var win = winBySci[s.sci] || 0;
|
var win = winBySci[s.sci] || 0;
|
||||||
|
var firstMs = Date.parse((s.first_seen || '').replace(' ', 'T'));
|
||||||
|
var isLifer = !isAllWindow && !isNaN(firstMs) && firstMs >= windowStartMs;
|
||||||
var sketchSrc = './avian/api/cutout.php?sci=' + encodeURIComponent(s.sci) +
|
var sketchSrc = './avian/api/cutout.php?sci=' + encodeURIComponent(s.sci) +
|
||||||
(s.com ? '&com=' + encodeURIComponent(s.com) : '') +
|
(s.com ? '&com=' + encodeURIComponent(s.com) : '') +
|
||||||
'&v=' + SKETCH_VERSION;
|
'&v=' + SKETCH_VERSION;
|
||||||
var audioSrc = './avian/api/recording.php?sci=' + encodeURIComponent(s.sci);
|
var audioSrc = './avian/api/recording.php?sci=' + encodeURIComponent(s.sci);
|
||||||
var spectroSrc = './avian/api/spectrogram.php?sci=' + encodeURIComponent(s.sci);
|
|
||||||
// The "all time" window makes the windowed count identical to the
|
// The "all time" window makes the windowed count identical to the
|
||||||
// all-time count - collapse to a single stat rather than print the
|
// all-time count - collapse to a single stat rather than print the
|
||||||
// same number twice. Otherwise label the count with its span.
|
// same number twice. Otherwise label the count with its span.
|
||||||
@@ -964,14 +971,15 @@
|
|||||||
: '<div><span class="n">' + fmtN(win) + '</span><span class="lbl-inline">' + windowLabel(currentHours) + '</span></div>'
|
: '<div><span class="n">' + fmtN(win) + '</span><span class="lbl-inline">' + windowLabel(currentHours) + '</span></div>'
|
||||||
+ '<div><span class="n">' + fmtN(total) + '</span><span class="lbl-inline">all time</span></div>';
|
+ '<div><span class="n">' + fmtN(total) + '</span><span class="lbl-inline">all time</span></div>';
|
||||||
return ''
|
return ''
|
||||||
+ '<article class="bird-card" data-sci="' + s.sci + '" data-audio="' + audioSrc + '" data-spectro="' + spectroSrc + '">'
|
+ '<article class="bird-card" data-sci="' + s.sci + '" data-audio="' + audioSrc + '">'
|
||||||
|
+ (isLifer ? '<span class="lifer-badge" title="new to the life list in this window">lifer</span>' : '')
|
||||||
+ '<div class="stat">' + statRows + '</div>'
|
+ '<div class="stat">' + statRows + '</div>'
|
||||||
+ '<div class="img-wrap">'
|
+ '<div class="img-wrap">'
|
||||||
+ '<img loading="lazy" decoding="async" src="' + sketchSrc + '" alt="' + s.com + '">'
|
+ '<img loading="lazy" decoding="async" src="' + sketchSrc + '" alt="' + s.com + '">'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<div class="spectro-wrap" aria-hidden="true"></div>'
|
|
||||||
+ '<h3>' + s.com + '</h3>'
|
+ '<h3>' + s.com + '</h3>'
|
||||||
+ '<div class="sci">' + s.sci + '</div>'
|
+ '<div class="sci">' + s.sci + '</div>'
|
||||||
|
+ '<div class="spectro-wrap" aria-hidden="true"></div>'
|
||||||
+ '<div class="actions">'
|
+ '<div class="actions">'
|
||||||
+ '<button type="button" class="chip play" data-action="play" aria-label="play recording">'
|
+ '<button type="button" class="chip play" data-action="play" aria-label="play recording">'
|
||||||
+ ICON_PLAY + '<span>play</span>'
|
+ ICON_PLAY + '<span>play</span>'
|
||||||
@@ -1037,17 +1045,41 @@
|
|||||||
var card = btn.closest('.bird-card');
|
var card = btn.closest('.bird-card');
|
||||||
if (btn === currentBtn) { stopCurrent(); return; }
|
if (btn === currentBtn) { stopCurrent(); return; }
|
||||||
stopCurrent();
|
stopCurrent();
|
||||||
|
audioClaim(stopCurrent); // stop any modal-recording / live-stream audio
|
||||||
setBtnState(btn, 'loading');
|
setBtnState(btn, 'loading');
|
||||||
currentBtn = btn;
|
currentBtn = btn;
|
||||||
// Kick off spectrogram load in parallel (it's a separate request).
|
// Render the spectrogram client-side from the recording's audio so
|
||||||
|
// it matches the active theme. paintSpectrogram paints with the
|
||||||
|
// --paper/--ink palette per data-theme (the same canvas the modal
|
||||||
|
// recordings use), instead of a fixed-colour PNG that can't follow
|
||||||
|
// light/dark mode. Decoded buffers are cached per URL.
|
||||||
var spectroWrap = card.querySelector('.spectro-wrap');
|
var spectroWrap = card.querySelector('.spectro-wrap');
|
||||||
if (spectroWrap && !spectroWrap.firstChild) {
|
if (spectroWrap && !spectroWrap.firstChild) {
|
||||||
var img = document.createElement('img');
|
var canvas = document.createElement('canvas');
|
||||||
img.loading = 'lazy';
|
spectroWrap.appendChild(canvas);
|
||||||
img.alt = '';
|
var aurl = card.dataset.audio;
|
||||||
img.src = card.dataset.spectro;
|
if (_decodedCache[aurl]) {
|
||||||
img.addEventListener('error', function () { spectroWrap.removeChild(img); });
|
paintSpectrogram(canvas, _decodedCache[aurl]);
|
||||||
spectroWrap.appendChild(img);
|
} else {
|
||||||
|
var actx = getSpecCtx();
|
||||||
|
if (actx) {
|
||||||
|
fetch(aurl)
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
|
||||||
|
.then(function (b) { return actx.decodeAudioData(b); })
|
||||||
|
.then(function (buf) {
|
||||||
|
_decodedCache[aurl] = buf;
|
||||||
|
// Guard on document containment, not spectroWrap.contains:
|
||||||
|
// a 30s refreshAll() poll can rebuild the atlas and detach
|
||||||
|
// this card mid-decode. The detached wrap still "contains"
|
||||||
|
// its canvas, but a detached node measures 0x0, which would
|
||||||
|
// trap paintSpectrogram in its size-retry loop forever.
|
||||||
|
if (document.contains(canvas)) paintSpectrogram(canvas, buf);
|
||||||
|
})
|
||||||
|
.catch(function () { if (spectroWrap.contains(canvas)) spectroWrap.removeChild(canvas); });
|
||||||
|
} else {
|
||||||
|
spectroWrap.removeChild(canvas);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Start audio.
|
// Start audio.
|
||||||
var audio = new Audio(card.dataset.audio);
|
var audio = new Audio(card.dataset.audio);
|
||||||
|
|||||||
@@ -791,8 +791,25 @@
|
|||||||
gap: 22px;
|
gap: 22px;
|
||||||
max-width: 1280px; margin: 0 auto;
|
max-width: 1280px; margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
/* Mobile atlas: a compact 2-up grid instead of one giant card per row,
|
||||||
|
so you can scan many birds and tap easily. Inline actions are hidden -
|
||||||
|
tapping the card opens the detail modal, which has play/wiki/ebird +
|
||||||
|
recordings - making the whole card one big touch target. */
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.atlas-grid { grid-template-columns: 1fr; gap: 16px; }
|
/* minmax(0,1fr) lets the columns shrink below their content's intrinsic
|
||||||
|
width (otherwise the chips force overflow). Selectors are scoped under
|
||||||
|
.atlas-grid so they out-specify the base .bird-card rules that appear
|
||||||
|
later in the file. */
|
||||||
|
.atlas-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||||
|
.atlas-grid .bird-card { min-height: 0; padding: 12px 12px 14px; border-radius: 9px; }
|
||||||
|
.atlas-grid .bird-card .stat { top: 10px; left: 11px; font-size: 8px; }
|
||||||
|
.atlas-grid .bird-card .stat .n { font-size: 11px; }
|
||||||
|
.atlas-grid .bird-card .img-wrap { margin: 30px 0 8px; }
|
||||||
|
.atlas-grid .bird-card .img-wrap img { max-height: 96px; }
|
||||||
|
.atlas-grid .bird-card h3 { font-size: 12.5px; }
|
||||||
|
.atlas-grid .bird-card .sci { font-size: 9.5px; }
|
||||||
|
.atlas-grid .bird-card .actions { display: none; }
|
||||||
|
.atlas-grid .lifer-badge { top: 9px; right: 9px; font-size: 7px; padding: 3px 5px; letter-spacing: 0.1em; }
|
||||||
}
|
}
|
||||||
.bird-card {
|
.bird-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -813,6 +830,18 @@
|
|||||||
font: 9px/1.5 ui-monospace, Menlo, monospace;
|
font: 9px/1.5 ui-monospace, Menlo, monospace;
|
||||||
color: var(--ink-soft); letter-spacing: 0.04em;
|
color: var(--ink-soft); letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
/* "lifer" badge - shown top-right when a species' first-ever detection
|
||||||
|
falls inside the selected window (set in renderAtlas). pointer-events
|
||||||
|
off so it never blocks the card's click-to-open. */
|
||||||
|
.lifer-badge {
|
||||||
|
position: absolute; top: 12px; right: 12px; z-index: 2;
|
||||||
|
font: 700 8px/1 ui-monospace, Menlo, monospace;
|
||||||
|
letter-spacing: 0.14em; text-transform: uppercase;
|
||||||
|
color: var(--paper); background: var(--accent);
|
||||||
|
padding: 4px 7px; border-radius: 999px;
|
||||||
|
box-shadow: 0 2px 6px rgba(26,22,18,0.18);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
.bird-card .stat .n {
|
.bird-card .stat .n {
|
||||||
color: var(--ink); font: 700 13px/1 ui-serif, Georgia, serif;
|
color: var(--ink); font: 700 13px/1 ui-serif, Georgia, serif;
|
||||||
font-variant-numeric: tabular-nums; margin-right: 4px;
|
font-variant-numeric: tabular-nums; margin-right: 4px;
|
||||||
@@ -1467,15 +1496,15 @@
|
|||||||
box-shadow: var(--recess);
|
box-shadow: var(--recess);
|
||||||
}
|
}
|
||||||
.bird-card .spectro-wrap:empty { display: none; }
|
.bird-card .spectro-wrap:empty { display: none; }
|
||||||
.bird-card .spectro-wrap img {
|
/* Canvas is painted client-side in the active theme's paper/ink palette
|
||||||
width: 100%; max-height: 44px; display: block;
|
(paintSpectrogram reads data-theme), so it follows light/dark mode with
|
||||||
object-fit: cover;
|
no CSS filter - same renderer as the modal recording spectrograms. */
|
||||||
filter: grayscale(1) brightness(0.94) contrast(1.16);
|
.bird-card .spectro-wrap canvas {
|
||||||
opacity: 0.78;
|
width: 100%; height: 44px; display: block;
|
||||||
transition: opacity 200ms ease;
|
|
||||||
}
|
}
|
||||||
.bird-card .spectro-wrap:hover img { opacity: 1; }
|
/* Progress wash: a translucent veil that grows as the clip plays. Ink in
|
||||||
/* Progress wash: a translucent ink veil that grows as the clip plays */
|
light mode; in dark mode a light veil so it stays visible over the
|
||||||
|
charcoal spectrogram ground. */
|
||||||
.bird-card .spectro-wrap::after {
|
.bird-card .spectro-wrap::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute; top: 0; bottom: 0; left: 0;
|
position: absolute; top: 0; bottom: 0; left: 0;
|
||||||
@@ -1484,6 +1513,9 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: width 60ms linear;
|
transition: width 60ms linear;
|
||||||
}
|
}
|
||||||
|
:root[data-theme="dark"] .bird-card .spectro-wrap::after {
|
||||||
|
background: linear-gradient(to right, rgba(236,232,225,0.26), rgba(236,232,225,0.05));
|
||||||
|
}
|
||||||
/* Playhead - thin vertical line at the leading edge */
|
/* Playhead - thin vertical line at the leading edge */
|
||||||
.bird-card .spectro-wrap::before {
|
.bird-card .spectro-wrap::before {
|
||||||
content: '';
|
content: '';
|
||||||
|
|||||||
Reference in New Issue
Block a user