1/* ===================================================================== 2 Expanding-panels engine for the project carousels 3 --------------------------------------------------------------------- 4 Turns #hscrollTrack into a row of expanding panels (accordion gallery): 5 click a collapsed panel to expand it; click the already-expanded panel 6 (or its CTA) to open the project. Replaces the old fan-stack 3D engine 7 while preserving filters, Grid/List toggle, Saved, dots/counter, 8 prev/next and deep links. 9 10 Loaded on: index.html, projects/index.html, design/index.html 11 ===================================================================== */ 12(function () { 13 'use strict'; 14 15 function init(stage) { 16 if (!stage || stage.__pcsPanels) return; 17 var allCards = Array.prototype.slice.call(stage.querySelectorAll('.proj-card')); 18 if (!allCards.length) return; 19 stage.__pcsPanels = true; 20 stage.classList.add('pcs-panels'); 21 22 // --- Optional controls (present on the homepage / projects page) --- 23 var wrap = stage.closest('.pcs-wrap') || 24 document.getElementById('hpProjectsCarousel') || 25 stage.parentElement; 26 var prevBtn = wrap && wrap.querySelector('.pcs-btn-prev'); 27 var nextBtn = wrap && wrap.querySelector('.pcs-btn-next'); 28 var dotsEl = document.getElementById('hpPcsDots') || (wrap && wrap.querySelector('.pcs-dots')); 29 var counter = document.getElementById('hpPcsCounter') || (wrap && wrap.querySelector('.pcs-counter')); 30 31 // Slugs whose project-page folder name differs from the card id. 32 var slugMap = { 33 'wolf-connection': 'wolf-connection-sanctuary', 34 'wildlife-on-the-edge': 'wildlife-on-the-edge-bears', 35 'going-wild': 'going-wild-ranger-training' 36 }; 37 function resolveHref(card) { 38 if (card.dataset.href) return card.dataset.href; 39 var id = card.dataset.id; 40 if (id) return '/projects/' + (slugMap[id] || id) + '/'; 41 return null; 42 } 43 44 function pad2(n) { return String(n).padStart(2, '0'); } 45 46 // --- Normalise each card for panel layout -------------------------- 47 allCards.forEach(function (card) { 48 // Wipe any inline styles left by the (now-removed) fan engine. 49 card.style.transform = ''; 50 card.style.opacity = ''; 51 card.style.zIndex = ''; 52 card.style.pointerEvents = ''; 53 card.style.display = ''; 54 card.setAttribute('role', 'link'); 55 if (!card.hasAttribute('tabindex')) card.setAttribute('tabindex', '0'); 56 var href = resolveHref(card); 57 if (href) card.dataset.href = href; 58 // Vertical mini-label for the collapsed sliver. 59 if (!card.querySelector('.pcs-vlabel')) { 60 var v = document.createElement('span'); 61 v.className = 'pcs-vlabel'; 62 v.setAttribute('aria-hidden', 'true'); 63 var h3 = card.querySelector('.meta h3, h3'); 64 v.textContent = card.dataset.name || (h3 ? h3.textContent.trim() : ''); 65 card.appendChild(v); 66 } 67 }); 68 69 var visible = []; 70 var active = 0; 71 72 function recollect() { 73 visible = allCards.filter(function (c) { return !c.classList.contains('is-filtered-out'); }); 74 if (active >= visible.length) active = Math.max(0, visible.length - 1); 75 } 76 77 function render(scrollToActive) { 78 recollect(); 79 allCards.forEach(function (c) { c.classList.remove('is-active'); }); 80 visible.forEach(function (c, i) { c.style.setProperty('--pcs-i', i); }); 81 82 var act = visible[active]; 83 if (act) act.classList.add('is-active'); 84 85 if (counter) { 86 counter.innerHTML = visible.length 87 ? '<b>' + pad2(active + 1) + '</b> / ' + pad2(visible.length) 88 : '<b>00</b> / 00'; 89 } 90 91 if (dotsEl) { 92 if (dotsEl.children.length !== visible.length) { 93 dotsEl.innerHTML = ''; 94 visible.forEach(function (_, i) { 95 var b = document.createElement('button'); 96 b.type = 'button'; 97 b.className = 'pcs-dot' + (i === active ? ' is-on' : ''); 98 b.setAttribute('aria-label', 'Show project ' + (i + 1)); 99 b.addEventListener('click', function () { active = i; render(true); }); 100 dotsEl.appendChild(b); 101 }); 102 } else { 103 Array.prototype.forEach.call(dotsEl.children, function (d, i) { 104 d.classList.toggle('is-on', i === active); 105 }); 106 } 107 } 108 109 if (prevBtn) prevBtn.disabled = visible.length < 2; 110 if (nextBtn) nextBtn.disabled = visible.length < 2; 111 112 if (scrollToActive && act && typeof act.scrollIntoView === 'function') { 113 act.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); 114 } 115 } 116 117 function setActiveCard(card) { 118 recollect(); 119 var i = visible.indexOf(card); 120 if (i >= 0 && i !== active) { active = i; render(true); return true; } 121 return false;
122 } 123 function goPrev() { if (visible.length < 2) return; active = (active - 1 + visible.length) % visible.length; render(true); } 124 function goNext() { if (visible.length < 2) return; active = (active + 1) % visible.length; render(true); } 125 126 // --- Click: expand a collapsed panel, or open the active one ------- 127 // Capture phase so we run before any legacy per-card navigate handler. 128 stage.addEventListener('click', function (e) { 129 var card = e.target.closest('.proj-card'); 130 if (!card || !stage.contains(card)) return; 131 // Inner links / buttons (leaf CTA, save heart) keep their own behaviour. 132 if (e.target.closest('a, button')) return; 133 // Swallow the click that ends a swipe. 134 if ((window.__pcsDragMoved || 0) > 8) { window.__pcsDragMoved = 0; return; } 135 136 if (!card.classList.contains('is-active')) { 137 e.preventDefault(); 138 e.stopImmediatePropagation(); 139 setActiveCard(card); 140 } else { 141 var dest = card.dataset.href; 142 if (dest) { 143 e.preventDefault(); 144 e.stopImmediatePropagation(); 145 window.location.href = dest; 146 } 147 } 148 }, true); 149 150 // --- Hover to expand on precise pointers (desktop) ----------------- 151 if (window.matchMedia && window.matchMedia('(hover:hover) and (pointer:fine)').matches) { 152 allCards.forEach(function (card) { 153 card.addEventListener('mouseenter', function () { 154 if (!card.classList.contains('is-filtered-out')) setActiveCard(card); 155 }); 156 }); 157 } 158 159 // --- Keyboard ------------------------------------------------------ 160 stage.addEventListener('keydown', function (e) { 161 if (e.key === 'ArrowLeft') { e.preventDefault(); goPrev(); } 162 else if (e.key === 'ArrowRight') { e.preventDefault(); goNext(); } 163 else if (e.key === 'Enter' || e.key === ' ') { 164 var c = visible[active]; 165 if (c && c.dataset.href) { e.preventDefault(); window.location.href = c.dataset.href; } 166 } 167 }); 168 169 // --- Drag / swipe moves the active panel --------------------------- 170 var dragging = null; 171 stage.addEventListener('pointerdown', function (e) { 172 if (e.button && e.button !== 0) return; 173 if (e.target.closest('.pcs-btn, .pcs-dot, a, button')) return; 174 dragging = { x: e.clientX, moved: 0 }; 175 }); 176 stage.addEventListener('pointermove', function (e) { 177 if (!dragging) return; 178 dragging.moved = Math.max(dragging.moved, Math.abs(e.clientX - dragging.x)); 179 }); 180 stage.addEventListener('pointerup', function (e) { 181 if (!dragging) return; 182 var dx = e.clientX - dragging.x; 183 window.__pcsDragMoved = dragging.moved; 184 setTimeout(function () { window.__pcsDragMoved = 0; }, 250); 185 var threshold = 55; 186 if (dx > threshold) goPrev(); 187 else if (dx < -threshold) goNext(); 188 dragging = null; 189 }); 190 stage.addEventListener('pointercancel', function () { dragging = null; }); 191 192 if (prevBtn) prevBtn.addEventListener('click', goPrev); 193 if (nextBtn) nextBtn.addEventListener('click', goNext); 194 195 // --- Filters: the page's own chip handler toggles .is-filtered-out; 196 // we just recollect + reset to the first match afterwards. ------- 197 document.querySelectorAll('.proj-chip').forEach(function (chip) { 198 chip.addEventListener('click', function () { 199 setTimeout(function () { active = 0; render(false); }, 0); 200 }); 201 }); 202 203 // Programmatic re-render hook. site.js dispatches 'pcs:refilter' on the 204 // stage after applying a deep-link filter (e.g. ?ecosystem=oceans), 205 // whose toggles land after our own chip listeners are bound; recollect 206 // the now-visible cards and reset to the first match. 207 stage.addEventListener('pcs:refilter', function () { active = 0; render(false); }); 208 209 // --- View toggle: list view is handled by CSS (:not(.view-list)); 210 // re-render when we come back to grid. ---------------------------- 211 document.querySelectorAll('.view-toggle button').forEach(function (b) { 212 b.addEventListener('click', function () { setTimeout(function () { render(false); }, 0); }); 213 }); 214 215 render(false); 216 217 // Enable transitions only after the initial layout has painted, so the 218 // first collapse/expand snaps into place instead of animating from the 219 // pre-panels card size (which can freeze off-screen). Use rAF for the 220 // common case, plus a timeout fallback for when rAF is paused (the tab 221 // is hidden / loaded in the background). 222 function markReady() { stage.classList.add('pcs-ready'); } 223 requestAnimationFrame(function () { requestAnimationFrame(markReady); }); 224 setTimeout(markReady, 120); 225 } 226 227 function boot() { init(document.getElementById('hscrollTrack')); } 228 229 if (document.readyState === 'loading') { 230 document.addEventListener('DOMContentLoaded', boot); 231 } else {
232 boot(); 233 } 234})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.