import { el, clear, onTeardown } from '../lib/dom.js'; import { loadSnapshot } from '../data/snapshot.js'; import { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js'; import { settings } from '../store/settings.js'; import { ui } from '../store/ui.js'; import { selection, stats } from '../store/selection.js'; import { Card } from '../components/Card.js'; import { ProgressRing } from '../components/ProgressRing.js'; const FILTERS = [ { key: 'all', label: 'All', test: () => true }, { key: 'caught', label: 'Caught', test: (e) => e.caught }, { key: 'missing', label: 'Missing', test: (e) => !e.caught }, { key: 'favorite', label: 'Favorites', test: (e) => e.favorite }, { key: 'legendary', label: 'Legendary', test: (e, sp) => sp.isLegendary || sp.isMythical }, ]; const SORTS = [ { key: 'dex', label: 'Dex number', desc: false }, { key: 'name', label: 'Name (A–Z)', desc: false }, { key: 'bst', label: 'Base stat total', desc: true }, { key: 'hp', label: 'HP', desc: true }, { key: 'atk', label: 'Attack', desc: true }, { key: 'def', label: 'Defense', desc: true }, { key: 'spa', label: 'Sp. Attack', desc: true }, { key: 'spd', label: 'Sp. Defense', desc: true }, { key: 'spe', label: 'Speed', desc: true }, { key: 'height', label: 'Height', desc: true }, { key: 'weight', label: 'Weight', desc: true }, { key: 'baseExp', label: 'Base EXP', desc: true }, { key: 'catchrate', label: 'Catch rate', desc: true }, { key: 'caught', label: 'Recently caught', desc: true }, ]; const STAT_SHORT = { hp: 'HP', atk: 'Atk', def: 'Def', spa: 'SpA', spd: 'SpD', spe: 'Spe' }; const SEARCH_ICON = ''; function sortValue(row, key) { const sp = row.species; switch (key) { case 'dex': return row.number ?? sp.id; case 'name': return sp.name; case 'bst': return sp.bst ?? 0; case 'hp': case 'atk': case 'def': case 'spa': case 'spd': case 'spe': return sp.stats?.[key] ?? 0; case 'height': return sp.height ?? 0; case 'weight': return sp.weight ?? 0; case 'baseExp': return sp.baseExp ?? 0; case 'catchrate': return sp.captureRate ?? 0; case 'caught': return selection.get().pokemon[sp.id]?.updatedAt || ''; default: return 0; } } function metricLabel(key, sp) { switch (key) { case 'bst': return `BST ${sp.bst}`; case 'hp': case 'atk': case 'def': case 'spa': case 'spd': case 'spe': return `${STAT_SHORT[key]} ${sp.stats?.[key] ?? 0}`; case 'height': return `${((sp.height ?? 0) / 10).toFixed(1)} m`; case 'weight': return `${((sp.weight ?? 0) / 10).toFixed(1)} kg`; case 'baseExp': return sp.baseExp != null ? `${sp.baseExp} EXP` : null; case 'catchrate': return `Catch ${sp.captureRate ?? '?'}`; default: return null; } } export async function DexGrid() { const view = el('section', { class: 'view dexgrid' }); const snap = await loadSnapshot(); const grid = el('div', { class: 'grid' }); let query = ''; let filterKey = ui.get().filter || 'all'; let sortKey = ui.get().sort || 'dex'; let sortDesc = !!ui.get().sortDesc; let rows = []; let ids = []; const ring = ProgressRing(); const title = el('h1', {}); // Which game is active — a quiet label, not a control. Change it from the // Games button in the nav. const gameLabel = el('span', { class: 'feed-head__game' }); const search = el('input', { class: 'field-search__input', type: 'search', placeholder: 'Filter this Pokédex…', oninput: (e) => { query = e.target.value.trim().toLowerCase(); paintGrid(); }, }); const filterBar = el('div', { class: 'feed-chips' }); const chipEls = FILTERS.map((f) => { const count = el('span', { class: 'feed-chip__count' }); const btn = el( 'button', { class: `feed-chip${f.key === filterKey ? ' is-active' : ''}`, type: 'button', onclick: () => { filterKey = f.key; ui.set({ filter: f.key }); chipEls.forEach((c, i) => c.btn.classList.toggle('is-active', FILTERS[i].key === f.key)); paintGrid(); resetScroll(); }, }, el('span', {}, f.label), count, ); filterBar.append(btn); return { btn, count }; }); const sortSelect = el( 'select', { class: 'feed-sort__select', onchange: (e) => { sortKey = e.target.value; sortDesc = SORTS.find((s) => s.key === sortKey).desc; ui.set({ sort: sortKey, sortDesc }); syncDirBtn(); paintGrid(); resetScroll(); }, }, ...SORTS.map((s) => el('option', { value: s.key, selected: s.key === sortKey }, s.label)), ); const dirBtn = el('button', { class: 'feed-sort__dir', type: 'button', title: 'Reverse order', onclick: () => { sortDesc = !sortDesc; ui.set({ sortDesc }); syncDirBtn(); paintGrid(); resetScroll(); }, }); function syncDirBtn() { dirBtn.textContent = sortDesc ? '↓' : '↑'; dirBtn.setAttribute('aria-label', sortDesc ? 'Descending' : 'Ascending'); } syncDirBtn(); // Changing the list order/contents makes the old scroll offset meaningless. function resetScroll() { ui.set({ feedScroll: 0 }); window.scrollTo(0, 0); } // Return to the saved scroll offset. Retry a few times — the grid's height // isn't final until the webfont loads and off-screen cards settle — but // stop once we've reached the target or the user starts scrolling. function restoreScroll() { const y = ui.get().feedScroll || 0; if (!y) return; let applied = null; const go = () => { if (applied != null && Math.abs(window.scrollY - applied) > 8) return; // user moved window.scrollTo(0, y); applied = Math.round(window.scrollY); }; go(); setTimeout(go, 80); setTimeout(go, 250); if (document.fonts && document.fonts.ready) document.fonts.ready.then(go); } view.append( el( 'header', { class: 'feed-head' }, el( 'div', { class: 'feed-head__top' }, el('div', { class: 'feed-head__id' }, title, gameLabel), ring.node, ), el( 'div', { class: 'field-search' }, el('span', { class: 'field-search__icon', html: SEARCH_ICON, 'aria-hidden': 'true' }), search, ), filterBar, el( 'div', { class: 'feed-sort' }, el('span', { class: 'feed-sort__label' }, 'Sort'), sortSelect, dirBtn, ), ), grid, ); function counts() { const p = selection.get().pokemon; let caught = 0; let favorite = 0; let legendary = 0; for (const { species } of rows) { const e = p[species.id]; if (e?.caught) caught++; if (e?.favorite) favorite++; if (species.isLegendary || species.isMythical) legendary++; } return { all: rows.length, caught, missing: rows.length - caught, favorite, legendary }; } function refreshMeta() { ring.update(stats(ids)); const c = counts(); chipEls.forEach(({ count }, i) => { count.textContent = c[FILTERS[i].key]; }); } function paintGrid() { const st = settings.get(); const pokemonState = selection.get().pokemon; const boxed = st.spriteStyle === 'game' && (snap.versionGroupByKey.get(st.versionGroup)?.generation ?? 9) <= 2; const filterTest = FILTERS.find((f) => f.key === filterKey).test; let list = rows.filter(({ species, number }) => { if ( query && !species.name.includes(query) && String(number) !== query && String(species.id) !== query ) { return false; } const e = pokemonState[species.id] || { seen: false, caught: false, favorite: false }; return filterTest(e, species); }); const dir = sortDesc ? -1 : 1; list = list .map((row) => ({ row, v: sortValue(row, sortKey) })) .sort((a, b) => { if (a.v < b.v) return -dir; if (a.v > b.v) return dir; return (a.row.number ?? a.row.species.id) - (b.row.number ?? b.row.species.id); }) .map((x) => x.row); const frag = document.createDocumentFragment(); list.forEach(({ species, number }, i) => { const card = Card(species, number, { spriteStyle: st.spriteStyle, versionGroup: st.versionGroup, boxed, metric: metricLabel(sortKey, species), }); if (i < 30) card.style.setProperty('--i', String(i)); frag.append(card); }); clear(grid); grid.append( list.length === 0 ? el('p', { class: 'grid__empty' }, 'Nothing matches those filters.') : frag, ); } function rebuild() { const st = settings.get(); const dex = resolvePokedex(snap, st); rows = dexRows(snap, dex); ids = rows.map((r) => r.species.id); title.textContent = prettify(dex.name || dex.key); gameLabel.textContent = st.versionGroup === 'all' ? 'All games' : prettify(st.versionGroup); refreshMeta(); paintGrid(); } let mountedVG = settings.get().versionGroup; rebuild(); // Return to where the user last scrolled (e.g. after viewing a Pokémon). requestAnimationFrame(restoreScroll); const offSettings = settings.subscribe((s) => { if (s.versionGroup !== mountedVG) { mountedVG = s.versionGroup; ui.set({ feedScroll: 0 }); } rebuild(); }); const offSelection = selection.subscribe(() => { refreshMeta(); if (filterKey !== 'all' && filterKey !== 'legendary') paintGrid(); else if (sortKey === 'caught') paintGrid(); }); onTeardown(view, () => { ui.set({ feedScroll: window.scrollY }); offSettings(); offSelection(); }); return view; }