1/* Bowery Boys Episode Index â v1.5 */ 2(function () { 3 'use strict'; 4 5 if (document.readyState === 'loading') { 6 document.addEventListener('DOMContentLoaded', init); 7 } else { 8 init(); 9 } 10 11 function init() { 12 var grid = document.getElementById('bb-ei-grid'); 13 if (!grid) return; 14 15 var cfg = window.bbEI || {}; 16 var batchSize = parseInt(cfg.batch, 10) || 25; 17 18 var searchEl = document.getElementById('bb-ei-search'); 19 var catEl = document.getElementById('bb-ei-cat'); 20 var yearEl = document.getElementById('bb-ei-year'); 21 var clearBtn = document.getElementById('bb-ei-clear'); 22 var clearBtn2 = document.getElementById('bb-ei-clear-2'); 23 var countNum = document.getElementById('bb-ei-count-num'); 24 var emptyState = document.getElementById('bb-ei-empty'); 25 var loadMoreWrap = document.getElementById('bb-ei-loadmore'); 26 var loadMoreBtn = document.getElementById('bb-ei-loadmore-btn'); 27 var remainingEl = document.getElementById('bb-ei-remaining'); 28 var viewGridBtn = document.getElementById('bb-ei-view-grid'); 29 var viewListBtn = document.getElementById('bb-ei-view-list'); 30 31 var cards = Array.prototype.slice.call(grid.querySelectorAll('.bb-ei-card')); 32 var total = cards.length; 33 34 // Build Fuse.js search index from card data attributes. 35 // Two separate instances so title and description/tag fields can have 36 // different thresholds â Fuse 6.x only supports a global threshold. 37 var fuseIndex = cards.map(function (card) { 38 return { 39 el: card, 40 title: card.dataset.title || '', 41 description: card.dataset.description || '', 42 tags: card.dataset.tags || '', 43 date: card.dataset.date || '' 44 }; 45 }); 46 var fuseShared = { 47 includeScore: true, // r.score must be present for intersection logic 48 ignoreLocation: true, // match keyword anywhere in the string 49 minMatchCharLength: 2 50 }; 51 // Title: allows ~1 character of variation (typos, plural 's', etc.) 52 var fuseTitle = new Fuse(fuseIndex, Object.assign({}, fuseShared, { 53 keys: ['title'], 54 threshold: 0.2 55 })); 56 // Description + tags: near-exact word presence only. 57 var fuseBody = new Fuse(fuseIndex, Object.assign({}, fuseShared, { 58 keys: ['description', 'tags'], 59 threshold: 0.1 60 })); 61 // Date: zero fuzziness â Bitap with 0 errors is exact substring match. 62 // 'july' matches 'july 2019' but not 'june 2019'. 63 var fuseDate = new Fuse(fuseIndex, Object.assign({}, fuseShared, { 64 keys: ['date'], 65 threshold: 0 66 })); 67 68 // Count how many cards PHP rendered as initially visible (no hidden attr). 69 var loadedCount = 0; 70 for (var i = 0; i < total; i++) { 71 if (!cards[i].hidden) loadedCount++; 72 } 73 // loadedCount === batchSize on a fresh page load. 74 75 var isFiltering = false; 76 var debounceTimer; 77 78 // ------------------------------------------------------------------------- 79 // Core: show/hide helpers 80 // ------------------------------------------------------------------------- 81 82 /** Re-insert all cards into the grid in their original DOM order. */ 83 function restoreOrder() { 84 for (var i = 0; i < total; i++) { 85 grid.appendChild(cards[i]); 86 } 87 } 88 89 /** Restore the grid to its "loaded up to loadedCount" state (no filter). */ 90 function restoreToLoaded() { 91 restoreOrder(); 92 for (var i = 0; i < total; i++) { 93 cards[i].hidden = (i >= loadedCount); 94 } 95 } 96 97 // ------------------------------------------------------------------------- 98 // Filtering â the [hidden] attribute is the single source of truth. 99 // No CSS class toggling, no specificity fights. 100 // ------------------------------------------------------------------------- 101 102 // Each filter clears the other two before applying, so every filter action 103 // always runs against the full episode list. 104 function onInput() { 105 clearTimeout(debounceTimer); 106 catEl.value = ''; 107 yearEl.value = ''; 108 catEl.classList.remove('bb-active'); 109 yearEl.classList.remove('bb-active'); 110 debounceTimer = setTimeout(applyFilters, 130); 111 } 112 113 function onCatChange() { 114 clearTimeout(debounceTimer); 115 searchEl.value = ''; 116 yearEl.value = ''; 117 yearEl.classList.remove('bb-active'); 118 applyFilters(); 119 } 120 121 function onYearChange() { 122 clearTimeout(debounceTimer); 123 searchEl.value = ''; 124 catEl.value = ''; 125 catEl.classList.remove('bb-active'); 126 applyFilters(); 127 } 128 129 function applyFilters() { 130 var q = searchEl.value.toLowerCase().trim(); 131 var cat = catEl.value;
132 var year = yearEl.value; 133 134 isFiltering = !!(q || cat || year); 135 136 clearBtn.hidden = !isFiltering; 137 catEl.classList.toggle('bb-active', !!cat); 138 yearEl.classList.toggle('bb-active', !!year); 139 140 if (isFiltering) { 141 // Fuzzy search: per-token score Maps, AND-intersected, with phrase boost. 142 // matchMap: Map<el, combinedScore> â null means no text search active. 143 var matchMap = null; 144 if (q) { 145 var tokens = q.split(/\s+/).filter(Boolean); 146 147 // For each token, build Map<el, bestScore> across title, body, and date. 148 var tokenMaps = tokens.map(function (token) { 149 var m = new Map(); 150 fuseTitle.search(token).forEach(function (r) { 151 m.set(r.item.el, r.score); 152 }); 153 fuseBody.search(token).forEach(function (r) { 154 var prev = m.get(r.item.el); 155 if (prev === undefined || r.score < prev) m.set(r.item.el, r.score); 156 }); 157 fuseDate.search(token).forEach(function (r) { 158 var prev = m.get(r.item.el); 159 if (prev === undefined || r.score < prev) m.set(r.item.el, r.score); 160 }); 161 return m; 162 }); 163 164 // Debug: log per-token match counts so we can see where results drop. 165 tokens.forEach(function (token, idx) { 166 console.log('[bbEI] token "' + token + '": ' + tokenMaps[idx].size + ' matches'); 167 }); 168 169 // AND intersection: an episode must match every token. 170 // Combined score = sum of per-token best scores (lower = more relevant). 171 matchMap = new Map(); 172 tokenMaps[0].forEach(function (score, el) { 173 var combined = score; 174 for (var t = 1; t < tokenMaps.length; t++) { 175 var s = tokenMaps[t].get(el); 176 if (s === undefined) { combined = null; return; } 177 combined += s; 178 } 179 if (combined !== null) matchMap.set(el, combined); 180 }); 181 182 console.log('[bbEI] after AND intersection: ' + matchMap.size + ' matches'); 183 184 // Phrase boost: if the full multi-word query matches as a phrase, 185 // multiply that episode's combined score by a fraction so it floats 186 // above episodes that merely contain all tokens separately. 187 if (tokens.length > 1) { 188 fuseTitle.search(q).forEach(function (r) { 189 if (matchMap.has(r.item.el)) { 190 matchMap.set(r.item.el, matchMap.get(r.item.el) * 0.25); 191 } 192 }); 193 fuseBody.search(q).forEach(function (r) { 194 if (matchMap.has(r.item.el)) { 195 matchMap.set(r.item.el, matchMap.get(r.item.el) * 0.4); 196 } 197 }); 198 } 199 } 200 201 // Evaluate ALL cards and collect the visible ones with their scores. 202 var visibleCards = []; 203 for (var i = 0; i < total; i++) { 204 var d = cards[i].dataset; 205 var show = 206 (!q || matchMap.has(cards[i])) && 207 (!cat || (' ' + d.cats + ' ').indexOf(' ' + cat + ' ') !== -1) && 208 (!year || d.year === year); 209 210 cards[i].hidden = !show; 211 if (show) visibleCards.push(cards[i]); 212 } 213 214 // When a text search is active, re-order visible cards by relevance score. 215 // appendChild moves each element to the end of the grid in sorted order; 216 // hidden cards stay in place and are invisible, so order doesn't matter. 217 if (q && visibleCards.length > 0) { 218 visibleCards.sort(function (a, b) { 219 return matchMap.get(a) - matchMap.get(b); 220 }); 221 visibleCards.forEach(function (el) { grid.appendChild(el); }); 222 } 223 224 countNum.textContent = visibleCards.length; 225 emptyState.hidden = visibleCards.length > 0; 226 if (loadMoreWrap) loadMoreWrap.hidden = true; 227 228 } else { 229 // No filter â snap back to the loaded batch. 230 restoreToLoaded(); 231 countNum.textContent = total; 232 emptyState.hidden = true; 233 updateLoadMore(); 234 } 235 } 236 237 // ------------------------------------------------------------------------- 238 // Load More 239 // ------------------------------------------------------------------------- 240 241 function updateLoadMore() { 242 if (!loadMoreWrap) return; 243 var left = total - loadedCount;
244 loadMoreWrap.hidden = (left <= 0); 245 if (remainingEl && left > 0) { 246 remainingEl.textContent = left + ' not yet shown'; 247 } 248 } 249 250 function loadMore() { 251 var end = Math.min(loadedCount + batchSize, total); 252 for (var i = loadedCount; i < end; i++) { 253 cards[i].hidden = false; 254 } 255 loadedCount = end; 256 257 // Scroll the first newly-revealed card into view. 258 if (cards[loadedCount - batchSize]) { 259 cards[loadedCount - batchSize].scrollIntoView({ behavior: 'smooth', block: 'nearest' }); 260 } 261 updateLoadMore(); 262 } 263 264 // ------------------------------------------------------------------------- 265 // Clear 266 // ------------------------------------------------------------------------- 267 268 function clearFilters() { 269 searchEl.value = ''; 270 catEl.value = ''; 271 yearEl.value = ''; 272 catEl.classList.remove('bb-active'); 273 yearEl.classList.remove('bb-active'); 274 isFiltering = false; 275 restoreToLoaded(); 276 countNum.textContent = total; 277 emptyState.hidden = true; 278 updateLoadMore(); 279 searchEl.focus(); 280 } 281 282 // ------------------------------------------------------------------------- 283 // URL param sync â shareable filtered links: ?topic=neighborhoods&year=2020 284 // ------------------------------------------------------------------------- 285 286 function syncFromURL() { 287 var p = new URLSearchParams(window.location.search); 288 if (p.get('topic')) catEl.value = p.get('topic'); 289 if (p.get('year')) yearEl.value = p.get('year'); 290 if (p.get('q')) searchEl.value = p.get('q'); 291 if (p.get('topic') || p.get('year') || p.get('q')) applyFilters(); 292 } 293 294 // ------------------------------------------------------------------------- 295 // View toggle â grid / list 296 // ------------------------------------------------------------------------- 297 298 var VIEW_KEY = 'bb-ei-view'; 299 300 function setView(view) { 301 if (view === 'list') { 302 grid.classList.add('bb-ei-list'); 303 if (viewGridBtn) { viewGridBtn.setAttribute('aria-pressed', 'false'); } 304 if (viewListBtn) { viewListBtn.setAttribute('aria-pressed', 'true'); } 305 } else { 306 grid.classList.remove('bb-ei-list'); 307 if (viewGridBtn) { viewGridBtn.setAttribute('aria-pressed', 'true'); } 308 if (viewListBtn) { viewListBtn.setAttribute('aria-pressed', 'false'); } 309 } 310 try { localStorage.setItem(VIEW_KEY, view); } catch (e) {} 311 } 312 313 function restoreView() { 314 var saved;
315 try { saved = localStorage.getItem(VIEW_KEY); } catch (e) {} 316 setView(saved === 'list' ? 'list' : 'grid'); 317 } 318 319 // ------------------------------------------------------------------------- 320 // Wire up 321 // ------------------------------------------------------------------------- 322 323 searchEl.addEventListener('input', onInput); 324 catEl.addEventListener('change', onCatChange); 325 yearEl.addEventListener('change', onYearChange); 326 clearBtn.addEventListener('click', clearFilters); 327 if (clearBtn2) clearBtn2.addEventListener('click', clearFilters); 328 if (loadMoreBtn) loadMoreBtn.addEventListener('click', loadMore); 329 if (viewGridBtn) viewGridBtn.addEventListener('click', function () { setView('grid'); }); 330 if (viewListBtn) viewListBtn.addEventListener('click', function () { setView('list'); }); 331 332 // Press "/" anywhere to focus the search box (like GitHub/YouTube). 333 document.addEventListener('keydown', function (e) { 334 if ( 335 e.key === '/' && 336 document.activeElement.tagName !== 'INPUT' && 337 document.activeElement.tagName !== 'TEXTAREA' && 338 document.activeElement.tagName !== 'SELECT' 339 ) { 340 e.preventDefault(); 341 searchEl.focus(); 342 searchEl.select(); 343 } 344 }); 345 346 updateLoadMore(); 347 restoreView(); 348 syncFromURL(); 349 } 350})();
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.