[FEAT] motion: staggered view entrances synced to the slide + card↔modal morph

This commit is contained in:
Twarner491
2026-06-05 16:36:56 -07:00
parent 85afadb058
commit 511e0bfb24
2 changed files with 276 additions and 87 deletions
+235 -79
View File
@@ -56,12 +56,34 @@
}, 220); }, 220);
} }
// The views slide horizontally over SLIDE_MS (see .views transition). For
// stats + atlas we hold the load-in hidden until the slide has essentially
// settled, so you watch the content populate *in* the view rather than it
// finishing mid-slide. The lead is a touch under SLIDE_MS so the cascade
// begins just as the view arrives - no dead pause, still snappy. Collage's
// bloom reads fine mid-slide, so it starts immediately (no lead). Stats
// reads as starting a hair slower than atlas, so it gets a shorter lead.
var SLIDE_MS = 480;
var SWITCH_LEAD = SLIDE_MS - 100; // atlas
var STATS_LEAD = SLIDE_MS - 200; // stats - begin a touch sooner
var currentView = 0; // collage shows first (no go() needed)
function go(i) { function go(i) {
i = Math.max(0, Math.min(2, i)); i = Math.max(0, Math.min(2, i));
// Only a genuine view *switch* replays the entrance. go() also fires when
// a card is expanded (it sets the #sci= hash, which routes through go(2))
// while already on the atlas - that must not retrigger the load-in.
var switching = (i !== currentView);
currentView = i;
views.style.transform = 'translateX(-' + (i * 100) + '%)'; views.style.transform = 'translateX(-' + (i * 100) + '%)';
btns.forEach(function (b, j) { b.setAttribute('aria-current', j === i ? 'true' : 'false'); }); btns.forEach(function (b, j) { b.setAttribute('aria-current', j === i ? 'true' : 'false'); });
syncPill(slider); syncPill(slider);
setTitleForView(i); setTitleForView(i);
if (!switching) return;
// Replay the view's entrance animation on switch (collage bloom,
// stats left-to-right, atlas row-by-row).
if (i === 0) playCollageEntrance();
else if (i === 1) playStatsEntrance(STATS_LEAD);
else if (i === 2) playAtlasEntrance(SWITCH_LEAD);
} }
btns.forEach(function (b) { b.addEventListener('click', function () { go(+b.dataset.i); }); }); btns.forEach(function (b) { b.addEventListener('click', function () { go(+b.dataset.i); }); });
@@ -351,14 +373,14 @@
return placed; return placed;
} }
function renderCollage(items) { function renderCollage(items, animate) {
collage.innerHTML = ''; collage.innerHTML = '';
if (!items.length) { if (!items.length) {
collage.innerHTML = '<p class="empty">no birds heard in this window.</p>'; collage.innerHTML = '<p class="empty">no birds heard in this window.</p>';
return; return;
} }
var W = collage.clientWidth, H = collage.clientHeight; var W = collage.clientWidth, H = collage.clientHeight;
if (!W || !H) { setTimeout(function () { renderCollage(items); }, 80); return; } if (!W || !H) { setTimeout(function () { renderCollage(items, animate); }, 80); return; }
// Tuning depends on bird count - same viewport, very different // Tuning depends on bird count - same viewport, very different
// pack densities for 6 vs 48 birds. // pack densities for 6 vs 48 birds.
@@ -494,6 +516,116 @@
// Stash the placed tiles so the alpha-mask hit-tester (below) can // Stash the placed tiles so the alpha-mask hit-tester (below) can
// resolve which silhouette the cursor is actually over. // resolve which silhouette the cursor is actually over.
collagePlaced = placed.filter(function (t) { return t.x > -1000; }); collagePlaced = placed.filter(function (t) { return t.x > -1000; });
// Bloom the birds in from the centre outward, but only when asked
// (first load, window change, view switch) - never on the silent 30s
// poll or a resize, which render without the animate flag.
if (animate) playCollageEntrance();
}
// Staggered centre-out entrance: each tile fades + scales in, delayed by
// its distance from the collage centre, so the flock blooms from the
// middle out. Re-applied with a reflow reset so it can replay on demand
// (e.g. switching back to the collage view).
var collageEntranceT = null;
function playCollageEntrance() {
var tiles = [].slice.call(collage.querySelectorAll('.gtile'));
if (!tiles.length) return;
var cx = collage.clientWidth / 2, cy = collage.clientHeight / 2;
var maxD = 1;
var info = tiles.map(function (t) {
var d = Math.hypot((t.offsetLeft + t.offsetWidth / 2) - cx,
(t.offsetTop + t.offsetHeight / 2) - cy);
if (d > maxD) maxD = d;
return { el: t, d: d };
});
var SPREAD = 520; // ms from the centre bird to the outermost
info.forEach(function (o) {
o.el.classList.remove('entering');
o.el.style.animationDelay = ((o.d / maxD) * SPREAD).toFixed(0) + 'ms';
});
void collage.offsetWidth; // commit the reset so the animation replays
info.forEach(function (o) { o.el.classList.add('entering'); });
// Safety net: the keyframe starts the tiles hidden (backwards fill), so
// if the animation never advances (a backgrounded/throttled tab where
// CSS animation time is frozen), strip the class after the bloom's
// worst-case duration so the birds always end visible. A no-op when the
// animation ran normally - it's already at the base (visible) state.
clearTimeout(collageEntranceT);
collageEntranceT = setTimeout(function () {
info.forEach(function (o) { o.el.classList.remove('entering'); o.el.style.animationDelay = ''; });
}, SPREAD + 520);
}
// Atlas entrance: cards rise + fade in row by row, top to bottom. Cards
// sharing an offsetTop are one row, so they appear together; each row
// down adds a small delay (capped so a long lifelist doesn't crawl).
var atlasEntranceT = null;
// lead: ms to hold every card hidden before the cascade starts. On a view
// switch this is set to ~the view-slide duration so the row-by-row load-in
// begins as the view settles (not while it's still sliding in). The cards'
// `backwards` fill keeps them hidden during the lead, so there's no flash.
// In-place re-renders (sort change) pass no lead - they fire immediately.
function playAtlasEntrance(lead) {
lead = lead || 0;
var grid = document.getElementById('atlasGrid');
if (!grid) return;
var cards = [].slice.call(grid.querySelectorAll('.bird-card'));
if (!cards.length) return;
var uniqTops = cards.map(function (c) { return c.offsetTop; })
.sort(function (a, b) { return a - b; })
.filter(function (v, i, a) { return i === 0 || v !== a[i - 1]; });
var rowOf = {}; uniqTops.forEach(function (t, i) { rowOf[t] = i; });
// Each row trails the one above by PER_ROW ms. At 90ms against the 480ms
// card animation the rows clearly cascade top-to-bottom (a row starts when
// the one above is ~1/5 in) instead of reading as one simultaneous fade.
// MAX_ROW caps the stagger so a long lifelist's off-screen rows don't crawl.
var PER_ROW = 90, MAX_ROW = 10;
cards.forEach(function (c) {
c.classList.remove('entering');
c.style.animationDelay = (lead + Math.min(rowOf[c.offsetTop] || 0, MAX_ROW) * PER_ROW) + 'ms';
});
void grid.offsetWidth;
cards.forEach(function (c) { c.classList.add('entering'); });
clearTimeout(atlasEntranceT);
atlasEntranceT = setTimeout(function () {
cards.forEach(function (c) { c.classList.remove('entering'); c.style.animationDelay = ''; });
}, lead + MAX_ROW * PER_ROW + 540);
}
// Stats entrance: timeline columns fade in left -> right (by their x
// position), with the side panel fading in just behind. Opacity only.
var statsEntranceT = null;
// lead: see playAtlasEntrance. On a view switch the whole graph is held
// hidden until the slide settles, then populates left-to-right; in-place
// re-renders (window-picker change) pass no lead and animate immediately.
function playStatsEntrance(lead) {
lead = lead || 0;
var plot = document.querySelector('.stats-tl-plot');
if (!plot) return;
var SPREAD = 460;
// The whole graph populates left-to-right: columns, gridlines and
// x-ticks stagger by their x%; the y-axis leads (delay 0) and the side
// panel trails. animationDelay carries the per-element offset.
var items = [].slice.call(plot.querySelectorAll('.stats-tl-col, .stats-tl-gridline, .stats-tl-xtick'))
.map(function (el) { return { el: el, d: ((parseFloat(el.style.left) || 0) / 100) * SPREAD }; });
var yaxis = document.querySelector('.stats-tl-yaxis');
if (yaxis) items.push({ el: yaxis, d: 0 });
// Side panel loads in tandem: section headers + captions lead, then
// their rows populate top-to-bottom over the same window as the graph.
var side = document.querySelector('.stats-side');
if (side) {
[].slice.call(side.querySelectorAll('h3, small')).forEach(function (el) { items.push({ el: el, d: 40 }); });
var rows = [].slice.call(side.querySelectorAll('li'));
rows.forEach(function (el, i) { items.push({ el: el, d: 80 + (i / Math.max(1, rows.length - 1)) * SPREAD }); });
}
items.forEach(function (o) { o.el.classList.remove('entering'); o.el.style.animationDelay = Math.round(lead + o.d) + 'ms'; });
void plot.offsetWidth;
items.forEach(function (o) { o.el.classList.add('entering'); });
clearTimeout(statsEntranceT);
statsEntranceT = setTimeout(function () {
items.forEach(function (o) { o.el.classList.remove('entering'); o.el.style.animationDelay = ''; });
}, lead + SPREAD + 560);
} }
// ---- Alpha-mask hover/click hit-testing ---- // ---- Alpha-mask hover/click hit-testing ----
@@ -587,9 +719,9 @@
// Collage renders whatever is in DATA.recent.species. When the picker // Collage renders whatever is in DATA.recent.species. When the picker
// changes, refreshRecent() refetches and re-renders. Empty state shows // changes, refreshRecent() refetches and re-renders. Empty state shows
// a "no detections in this window" message. // a "no detections in this window" message.
function renderCollageFromData() { function renderCollageFromData(animate) {
var items = (DATA.recent && DATA.recent.species) || []; var items = (DATA.recent && DATA.recent.species) || [];
renderCollage(items); renderCollage(items, animate);
} }
var rTimer; var rTimer;
window.addEventListener('resize', function () { window.addEventListener('resize', function () {
@@ -900,7 +1032,7 @@
var ICON_PLAY = '<svg viewBox="0 0 12 12" fill="currentColor"><path d="M3 2 L10 6 L3 10 Z"/></svg>'; var ICON_PLAY = '<svg viewBox="0 0 12 12" fill="currentColor"><path d="M3 2 L10 6 L3 10 Z"/></svg>';
var ICON_PAUSE = '<svg viewBox="0 0 12 12" fill="currentColor"><rect x="3" y="2" width="2.5" height="8"/><rect x="6.5" y="2" width="2.5" height="8"/></svg>'; var ICON_PAUSE = '<svg viewBox="0 0 12 12" fill="currentColor"><rect x="3" y="2" width="2.5" height="8"/><rect x="6.5" y="2" width="2.5" height="8"/></svg>';
function renderAtlas() { function renderAtlas(animate) {
var grid = document.getElementById('atlasGrid'); var grid = document.getElementById('atlasGrid');
if (!grid) return; if (!grid) return;
@@ -1126,26 +1258,26 @@
btn.click(); btn.click();
} }
}); });
if (animate) playAtlasEntrance();
} }
function renderWindowDependent() { function renderWindowDependent(animate) {
// Things that change with the time-window picker. drawHistograms is // renderStatsLists runs BEFORE drawHistograms so the stats entrance
// here too now that its X-axis spans the selected window (was only // (fired at the end of drawHistograms) can stagger the side-panel rows
// re-drawn on full refreshAll before). // that were just built, in tandem with the graph populating.
renderCollageFromData(); renderCollageFromData(animate);
drawHistograms();
renderStatsLists(); renderStatsLists();
renderAtlas(); drawHistograms(animate);
renderAtlas(animate);
} }
function renderTimeIndependent() { function renderTimeIndependent(animate) {
// Stats charts + atlas/stats lists that derive from non-window data // Lists first, then the graph (see renderWindowDependent).
// (totals, lifelist, timeseries).
drawHistograms();
renderStatsLists(); renderStatsLists();
renderAtlas(); drawHistograms(animate);
renderAtlas(animate);
} }
function refreshRecent() { function refreshRecent(animate) {
// Capture the window this fetch was issued for. If the user // Capture the window this fetch was issued for. If the user
// changes the picker again before it resolves - or a slower poll // changes the picker again before it resolves - or a slower poll
// lands later - we discard the stale response so the collage // lands later - we discard the stale response so the collage
@@ -1154,11 +1286,11 @@
return fetchJson('./avian/api/birdnet-api.php?action=recent&hours=' + forHours) return fetchJson('./avian/api/birdnet-api.php?action=recent&hours=' + forHours)
.then(function (j) { .then(function (j) {
if (forHours !== currentHours) return; // window changed mid-flight if (forHours !== currentHours) return; // window changed mid-flight
DATA.recent = j; renderWindowDependent(); DATA.recent = j; renderWindowDependent(animate);
}) })
.catch(function (e) { console.warn('recent fetch failed', e); }); .catch(function (e) { console.warn('recent fetch failed', e); });
} }
function refreshAll() { function refreshAll(animate) {
var forHours = currentHours; var forHours = currentHours;
return Promise.all([ return Promise.all([
fetchJson('./avian/api/birdnet-api.php?action=stats').catch(function () { return null; }), fetchJson('./avian/api/birdnet-api.php?action=stats').catch(function () { return null; }),
@@ -1175,18 +1307,20 @@
// since this poll started - otherwise keep what's there. // since this poll started - otherwise keep what's there.
if (forHours === currentHours && parts[4]) DATA.recent = parts[4]; if (forHours === currentHours && parts[4]) DATA.recent = parts[4];
recomputeDerived(); recomputeDerived();
renderTimeIndependent(); renderTimeIndependent(animate);
renderCollageFromData(); renderCollageFromData(animate);
}); });
} }
// Kick off the initial fetch. Renders pull from DATA as soon as it // Kick off the initial fetch. Renders pull from DATA as soon as it
// populates; until then the page sits with empty histograms + lists. // populates; until then the page sits with empty histograms + lists.
refreshAll(); // animate=true so the collage blooms in on first load.
refreshAll(true);
// Hook into the window picker so the data refetches on change. // Hook into the window picker so the data refetches on change. Pass
// animate=true so the collage blooms (the silent poll passes nothing).
winBtns.forEach(function (b) { winBtns.forEach(function (b) {
b.addEventListener('click', function () { refreshRecent(); }); b.addEventListener('click', function () { refreshRecent(true); });
}); });
// ---- Realtime polling ---- // ---- Realtime polling ----
@@ -1929,72 +2063,94 @@
}); });
} }
// FLIP morph helpers. We never resize/reposition the modal-card // Shared-element morph: the modal-card scales+translates from the
// permanently - we apply an inline transform that places it at the // clicked atlas card's exact rect to its natural centred rect, so the
// source-card's position+scale, then clear it next frame so the // little card appears to expand into the big one (and retract on
// browser interpolates to the natural state. The same trick runs in // close). Only the card transforms; the container's opacity does the
// reverse on close. // single fade for backdrop + card together - no double-fade, and the
// transform is cleared only once hidden so there's no mid-close snap.
var atlasGridEl = document.getElementById('atlasGrid'); var atlasGridEl = document.getElementById('atlasGrid');
function morphFromRect(cardEl) { function morphTransform(modalCard, sourceCard) {
if (!cardEl) return null; if (!modalCard || !sourceCard) return null;
var r = cardEl.getBoundingClientRect(); var s = sourceCard.getBoundingClientRect();
var winCx = window.innerWidth / 2; // Source off-screen (opened from stats mid-slide, or scrolled away)
var winCy = window.innerHeight / 2; // -> skip the morph and just fade, rather than fly in from nowhere.
var dx = (r.left + r.width / 2) - winCx; if (!s.width || s.bottom < 0 || s.top > window.innerHeight ||
var dy = (r.top + r.height / 2) - winCy; s.right < 0 || s.left > window.innerWidth) return null;
// Scale relative to the natural max width of the modal (~920px). var m = modalCard.getBoundingClientRect();
var ratio = Math.max(0.18, Math.min(0.95, r.width / 920)); if (!m.width) return null;
return { dx: dx, dy: dy, ratio: ratio }; var scale = Math.max(0.1, s.width / m.width);
var dx = (s.left + s.width / 2) - (m.left + m.width / 2);
var dy = (s.top + s.height / 2) - (m.top + m.height / 2);
return 'translate3d(' + dx.toFixed(1) + 'px,' + dy.toFixed(1) + 'px,0) scale(' + scale.toFixed(4) + ')';
}
// Run cb once the transform transition finishes, with a timeout
// fallback for environments where transitionend doesn't fire.
function onceTransformEnd(el, cb, fallbackMs) {
var fired = false;
function handler(ev) {
if (ev && ev.propertyName && ev.propertyName !== 'transform') return;
if (fired) return;
fired = true;
el.removeEventListener('transitionend', handler);
cb();
}
el.addEventListener('transitionend', handler);
setTimeout(handler, fallbackMs);
} }
function morphModalOpen(modalCard, sourceCard) { function morphModalOpen(modalCard, sourceCard) {
if (!modalCard) return; var modal = document.getElementById('detail-modal');
if (!modalCard) { modal.classList.add('is-open'); return; }
// Identity first so we can measure the card's natural rect, then jump
// it (no transition) to the source card's position + scale.
modalCard.classList.remove('is-morphing'); modalCard.classList.remove('is-morphing');
var from = morphFromRect(sourceCard); modalCard.style.transform = '';
if (from) {
modalCard.style.transformOrigin = '50% 50%';
modalCard.style.transform =
'translate3d(' + from.dx + 'px, ' + from.dy + 'px, 0) scale(' + from.ratio + ')';
modalCard.style.opacity = '0';
} else {
modalCard.style.transform = 'translate3d(0, 8px, 0) scale(.96)';
modalCard.style.opacity = '0';
}
// Force a layout flush so the starting state is committed, then
// schedule the destination on the next tick. setTimeout(0) is
// more reliable than rAF in some embedded/headless contexts.
void modalCard.offsetWidth; void modalCard.offsetWidth;
var start = morphTransform(modalCard, sourceCard);
if (start) {
modalCard.style.transform = start;
void modalCard.offsetWidth;
}
// Next tick: fade the container in and glide the card to identity.
// setTimeout (not rAF) - rAF can stall in non-painting/headless
// contexts; the forced reflow above already commits the start
// transform so the transition interpolates cleanly from it.
setTimeout(function () { setTimeout(function () {
modalCard.classList.add('is-morphing'); modal.classList.add('is-open');
// Explicit identity matrix - browsers won't interpolate if (start) {
// between a matrix() and the keyword "none". modalCard.classList.add('is-morphing');
modalCard.style.transform = 'translate3d(0px, 0px, 0px) scale(1)'; modalCard.style.transform = 'translate3d(0,0,0) scale(1)';
modalCard.style.opacity = '1'; }
setTimeout(function () { }, 0);
if (start) {
onceTransformEnd(modalCard, function () {
modalCard.classList.remove('is-morphing'); modalCard.classList.remove('is-morphing');
modalCard.style.transform = ''; modalCard.style.transform = '';
modalCard.style.opacity = ''; }, 360);
}, 420); }
}, 0);
} }
function morphModalClose(modalCard, sourceCard, done) { function morphModalClose(modalCard, sourceCard, done) {
if (!modalCard) { if (done) done(); return; } var modal = document.getElementById('detail-modal');
var from = morphFromRect(sourceCard); // Fade the container out (backdrop + card) and retract the card to
modalCard.classList.add('is-morphing'); // the source rect at the same time.
if (from) { modal.classList.remove('is-open');
modalCard.style.transform = var end = modalCard ? morphTransform(modalCard, sourceCard) : null;
'translate3d(' + from.dx + 'px, ' + from.dy + 'px, 0) scale(' + from.ratio + ')'; var finish = function () {
} else { if (modalCard) {
modalCard.style.transform = 'translate3d(0, 8px, 0) scale(.96)'; modalCard.classList.remove('is-morphing');
} modalCard.style.transform = '';
modalCard.style.opacity = '0'; }
// After the transition, reset state for next open.
var settle = function () {
modalCard.classList.remove('is-morphing');
modalCard.style.transform = '';
modalCard.style.opacity = '';
if (done) done(); if (done) done();
}; };
setTimeout(settle, 380); if (modalCard && end) {
modalCard.classList.add('is-morphing');
void modalCard.offsetWidth;
modalCard.style.transform = end;
onceTransformEnd(modalCard, finish, 360);
} else {
// No morph -> let the container opacity fade run, then hide.
setTimeout(finish, 280);
}
} }
// Pose toggle inside the modal - swaps the sketch between perched // Pose toggle inside the modal - swaps the sketch between perched
+41 -8
View File
@@ -499,6 +499,41 @@
flex: 1 1 auto; width: 100%; max-width: 1300px; margin: 0 auto; flex: 1 1 auto; width: 100%; max-width: 1300px; margin: 0 auto;
position: relative; min-height: 0; position: relative; min-height: 0;
} }
/* Centre-out bloom: each tile fades + scales in; JS staggers the
animation-delay by distance from the cluster centre. backwards
fill-mode holds the hidden start state through the delay, and we keep
no forwards transform so the hover scale still works afterward. */
@keyframes gtile-in {
from { opacity: 0; transform: scale(0.82); }
to { opacity: 1; transform: scale(1); }
}
.gtile.entering {
animation: gtile-in 420ms cubic-bezier(.2,.7,.3,1) backwards;
}
@media (prefers-reduced-motion: reduce) {
.gtile.entering { animation: none; }
}
/* Atlas: cards rise + fade in, row by row (JS staggers by row). */
@keyframes bird-card-in {
from { opacity: 0; transform: translateY(16px) scale(0.96); }
to { opacity: 1; transform: none; }
}
.bird-card.entering { animation: bird-card-in 480ms cubic-bezier(.2,.7,.3,1) backwards; }
/* Stats: the WHOLE graph populates left-to-right - columns, gridlines and
x-axis ticks stagger by their x; the y-axis + side panel fade in just
behind. Opacity only (these elements use transform for layout, so
animating transform here would fight their positioning). */
@keyframes stats-fade-in { from { opacity: 0; } to { opacity: 1; } }
.stats-tl-col.entering, .stats-tl-gridline.entering,
.stats-tl-xtick.entering, .stats-tl-yaxis.entering,
.stats-side h3.entering, .stats-side small.entering, .stats-side li.entering {
animation: stats-fade-in 340ms ease backwards;
}
@media (prefers-reduced-motion: reduce) {
.bird-card.entering, .stats-tl-col.entering, .stats-tl-gridline.entering,
.stats-tl-xtick.entering, .stats-tl-yaxis.entering,
.stats-side h3.entering, .stats-side small.entering, .stats-side li.entering { animation: none; }
}
.gtile { .gtile {
position: absolute; position: absolute;
background: transparent; border: 0; padding: 0; margin: 0; background: transparent; border: 0; padding: 0; margin: 0;
@@ -1198,9 +1233,12 @@
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
padding: 24px; padding: 24px;
pointer-events: none; opacity: 0; pointer-events: none; opacity: 0;
transition: opacity 220ms ease; /* One opacity fade for backdrop + card together; the card also
morphs via transform (see morphModalOpen). Driven by .is-open so
the fade can run *during* the close morph, not after it. */
transition: opacity 260ms ease;
} }
#detail-modal[aria-hidden="false"] { pointer-events: auto; opacity: 1; } #detail-modal.is-open { pointer-events: auto; opacity: 1; }
.modal-backdrop { .modal-backdrop {
position: absolute; inset: 0; position: absolute; inset: 0;
background: rgba(26,22,18,0.32); background: rgba(26,22,18,0.32);
@@ -1224,13 +1262,8 @@
will-change: transform, opacity; will-change: transform, opacity;
} }
.modal-card.is-morphing { .modal-card.is-morphing {
transition: transition: transform 300ms cubic-bezier(.22,.61,.36,1);
transform 380ms cubic-bezier(.32,.72,.32,1),
opacity 220ms ease;
} }
/* The static aria-hidden=false rule used to override transform. It
now conflicts with our inline FLIP morph - removed. The morph
handler explicitly sets the destination transform. */
.modal-close { .modal-close {
position: absolute; top: 16px; right: 16px; position: absolute; top: 16px; right: 16px;
background: var(--paper-2); border: 0; background: var(--paper-2); border: 0;