import { el, clear, onTeardown } from '../lib/dom.js'; import { loadSnapshot } from '../data/snapshot.js'; import { settings } from '../store/settings.js'; import { ui } from '../store/ui.js'; import { entry } from '../store/selection.js'; import { prettify } from '../data/pokedex-resolver.js'; import { Sprite } from '../components/Sprite.js'; import { TypeChip } from '../components/TypeChip.js'; const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop()); const loose = (s) => s.toLowerCase().replace(/[-\s]+/g, ' ').trim(); const DMG = { physical: 'Phys', special: 'Spec', status: 'Stat' }; const TYPES = [ 'normal', 'fire', 'water', 'electric', 'grass', 'ice', 'fighting', 'poison', 'ground', 'flying', 'psychic', 'bug', 'rock', 'ghost', 'dragon', 'dark', 'steel', 'fairy', ]; const itemSprite = (name) => `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/${name}.png`; const TABS = [ { id: 'pokemon', label: 'Pokémon' }, { id: 'moves', label: 'Moves' }, { id: 'items', label: 'Items' }, { id: 'abilities', label: 'Abilities' }, ]; /** Numeric sort key from a TM/HM/TR label like "TM25": TMs, then HMs, then TRs. */ const tmSortKey = (label) => { if (!label) return 1e9; const m = String(label).match(/^([A-Z]+)0*(\d+)$/); if (!m) return 1e9 - 1; return (({ TM: 0, HM: 1, TR: 2 })[m[1]] ?? 3) * 1000 + Number(m[2]); }; export async function SearchView() { const snap = await loadSnapshot(); const view = el('section', { class: 'view search' }); let tab = TABS.some((t) => t.id === ui.get().searchTab) ? ui.get().searchTab : 'pokemon'; let query = ui.get().searchQuery || ''; const input = el('input', { class: 'search__input', type: 'search', value: query, oninput: (e) => { query = e.target.value; ui.set({ searchQuery: query }); run(); }, }); const seg = el( 'div', { class: 'seg' }, ...TABS.map((t) => el( 'button', { class: `seg__btn${t.id === tab ? ' is-active' : ''}`, type: 'button', onclick: () => { tab = t.id; ui.set({ searchTab: t.id }); [...seg.children].forEach((b, i) => b.classList.toggle('is-active', TABS[i].id === tab)); syncUI(); run(); }, }, t.label, ), ), ); const filters = el('div', { class: 'lookup__filters' }); const note = el('p', { class: 'search__note' }); const results = el('div', { class: 'search__results' }); // ---- filter controls (rebuilt per tab) --------------------------- function pill(label, options, value, onChange) { return el( 'select', { class: 'feed-sort__select', onchange: (e) => onChange(e.target.value) }, ...options.map(([v, t]) => el('option', { value: v, selected: v === value }, t)), ); } // "TMs only" / "HMs only" checkboxes. Toggling either rebuilds the filter // row so the sort dropdown gains/loses "TM / HM number", and drops that // sort if it's active once neither is on. function machineToggle(key, label) { return el( 'label', { class: 'lookup__check' }, el('input', { type: 'checkbox', checked: !!ui.get()[key], onchange: (e) => { ui.set({ [key]: e.target.checked }); if (!ui.get().mvTm && !ui.get().mvHm && ui.get().mvSort === 'tm') { ui.set({ mvSort: 'name' }); } buildFilters(); run(); }, }), label, ); } function buildFilters() { clear(filters); if (tab === 'moves') { const vg = settings.get().versionGroup; const gameHasKind = (kind) => snap.moves.some((m) => m.machines.some( (x) => (vg === 'all' || x.vg === vg) && (x.tm || '').startsWith(kind), ), ); const hasTm = gameHasKind('TM'); const hasHm = gameHasKind('HM'); // false for Gen 8+ games, which dropped HMs // A machine filter left on for a game that has no such machines would // silently zero the list with no visible checkbox to switch it off. if ((!hasTm && ui.get().mvTm) || (!hasHm && ui.get().mvHm)) { ui.set({ mvTm: hasTm && ui.get().mvTm, mvHm: hasHm && ui.get().mvHm }); if (!ui.get().mvTm && !ui.get().mvHm && ui.get().mvSort === 'tm') { ui.set({ mvSort: 'name' }); } } filters.append( ...[ pill('mvType', [['', 'Any type'], ...TYPES.map((t) => [t, prettify(t)])], ui.get().mvType, (v) => { ui.set({ mvType: v }); run(); }), pill( 'mvClass', [['', 'Any category'], ['physical', 'Physical'], ['special', 'Special'], ['status', 'Status']], ui.get().mvClass, (v) => { ui.set({ mvClass: v }); run(); }, ), pill( 'mvSort', [ ['name', 'A–Z'], ['power', 'Power'], ['accuracy', 'Accuracy'], ['gen', 'Newest'], // "By number" only makes sense with a TM/HM filter on. ...(ui.get().mvTm || ui.get().mvHm ? [['tm', 'TM / HM number']] : []), ], ui.get().mvSort, (v) => { ui.set({ mvSort: v }); run(); }, ), hasTm ? machineToggle('mvTm', 'TMs only') : null, hasHm ? machineToggle('mvHm', 'HMs only') : null, ].filter(Boolean), ); } else if (tab === 'items') { filters.append( pill( 'itCat', [['', 'Any category'], ...snap.itemCategories.map((c) => [c, prettify(c)])], ui.get().itCat, (v) => { ui.set({ itCat: v }); run(); }, ), pill('itSort', [['name', 'A–Z'], ['id', 'Dex order']], ui.get().itSort, (v) => { ui.set({ itSort: v }); run(); }), ); } else if (tab === 'abilities') { filters.append( pill( 'abSort', [['name', 'A–Z'], ['count', 'Most Pokémon'], ['gen', 'Newest']], ui.get().abSort, (v) => { ui.set({ abSort: v }); run(); }, ), ); } } function syncUI() { input.placeholder = tab === 'pokemon' ? 'Name, number or type…' : tab === 'moves' ? 'Move name…' : tab === 'items' ? 'Item name…' : 'Ability name or effect…'; buildFilters(); } view.append( el('header', { class: 'view__header' }, el('h1', {}, 'Search'), note), seg, filters, input, results, ); syncUI(); // ---- Pokémon ------------------------------------------------- function renderPokemon(q) { const style = settings.get().spriteStyle; const matches = snap.species .filter( (s) => loose(s.name).includes(q) || String(s.id) === q || (s.types || []).some((t) => t === q), ) .slice(0, 60); if (!matches.length) return void results.append(el('p', { class: 'search__empty' }, 'No matches.')); for (const s of matches) { const e = entry(s.id); results.append( el( 'a', { class: `search__row${e.caught ? ' is-caught' : ''}`, href: `#/pokemon/${s.id}` }, Sprite(s.id, { style, alt: s.name, size: 52 }), el('span', { class: 'search__num' }, `#${String(s.id).padStart(4, '0')}`), el('span', { class: 'search__name' }, s.name.replace(/-/g, ' ')), el('span', { class: 'search__types' }, ...(s.types || []).map(TypeChip)), ), ); } } // ---- Moves (from snapshot, offline) ----------------------------- function renderMoves(q) { const st = settings.get(); const vg = st.versionGroup; const type = ui.get().mvType; const cls = ui.get().mvClass; const sort = ui.get().mvSort; const tmOnly = !!ui.get().mvTm; const hmOnly = !!ui.get().mvHm; const machineFilter = tmOnly || hmOnly; const wantKinds = [tmOnly && 'TM', hmOnly && 'HM'].filter(Boolean); const kindOf = (x) => (x.tm || '').slice(0, 2); // TM | HM | TR // The machine entry to show / sort / filter by. For a specific game // it's that game's entry; for National ("all") — where a move can be a // TM in one game and an HM in another — it's the newest entry of a kind // the filter is asking for. TM/HM numbers are baked into the snapshot. const machineFor = (m) => { if (vg !== 'all') return m.machines.find((x) => x.vg === vg) || null; if (wantKinds.length) { return [...m.machines].reverse().find((x) => wantKinds.includes(kindOf(x))) || null; } return m.machines[m.machines.length - 1] || null; }; let list = snap.moves.filter((m) => { if (q && !loose(m.name).includes(q)) return false; if (type && m.type !== type) return false; if (cls && m.damageClass !== cls) return false; if (machineFilter) { const e = machineFor(m); if (!e) return false; // machineFor already restricted the kind for National; for a // specific game, check the game's own entry is the right kind. if (vg !== 'all' && !wantKinds.includes(kindOf(e))) return false; } return true; }); list.sort((a, b) => { if (sort === 'power') return (b.power ?? -1) - (a.power ?? -1) || a.name.localeCompare(b.name); if (sort === 'accuracy') return (b.accuracy ?? -1) - (a.accuracy ?? -1) || a.name.localeCompare(b.name); if (sort === 'gen') return b.generation - a.generation || a.name.localeCompare(b.name); if (sort === 'tm') { return ( tmSortKey(machineFor(a)?.tm) - tmSortKey(machineFor(b)?.tm) || a.name.localeCompare(b.name) ); } return a.name.localeCompare(b.name); }); note.textContent = `${list.length} move${list.length === 1 ? '' : 's'}`; const shown = list.slice(0, 400); for (const m of shown) { const tmCell = machineFilter ? el('span', { class: 'moverow__tm' }, machineFor(m)?.tm || '') : null; results.append( el( 'a', { class: 'search__row search__row--slim', href: `#/move/${m.id}` }, tmCell, el('span', { class: 'search__name' }, m.name.replace(/-/g, ' ')), el( 'span', { class: 'search__types' }, TypeChip(m.type), el('span', { class: 'moverow__cat', dataset: { cat: m.damageClass || '' } }, DMG[m.damageClass] || '—'), el('span', { class: 'search__num' }, m.power != null ? `${m.power} pw` : ''), ), ), ); } if (list.length > shown.length) { results.append(el('p', { class: 'search__empty' }, `Showing ${shown.length} of ${list.length} — refine to see more.`)); } } // ---- Items (from snapshot, offline) --------------------------- function renderItems(q) { const cat = ui.get().itCat; const sort = ui.get().itSort; let list = snap.items.filter((it) => { if (q && !loose(it.name).includes(q)) return false; if (cat && it.category !== cat) return false; return true; }); list.sort((a, b) => (sort === 'id' ? a.id - b.id : a.name.localeCompare(b.name))); note.textContent = `${list.length} item${list.length === 1 ? '' : 's'}`; const shown = list.slice(0, 400); for (const it of shown) { const icon = el('img', { class: 'search__item-icon', loading: 'lazy', alt: '', src: itemSprite(it.name) }); icon.addEventListener('error', () => icon.remove(), { once: true }); results.append( el( 'a', { class: 'search__row', href: `#/item/${it.id}` }, icon, el('span', { class: 'search__name' }, it.name.replace(/-/g, ' ')), el('span', { class: 'search__cat' }, prettify(it.category)), ), ); } if (list.length > shown.length) { results.append(el('p', { class: 'search__empty' }, `Showing ${shown.length} of ${list.length} — refine to see more.`)); } } // ---- Abilities (from snapshot, offline) --------------------- function renderAbilities(q) { const sort = ui.get().abSort; let list = snap.abilities.filter( (a) => !q || loose(a.name).includes(q) || a.effect.toLowerCase().includes(q), ); list.sort((a, b) => { if (sort === 'count') return b.pokemon.length - a.pokemon.length || a.name.localeCompare(b.name); if (sort === 'gen') return b.generation - a.generation || a.name.localeCompare(b.name); return a.name.localeCompare(b.name); }); note.textContent = `${list.length} abilit${list.length === 1 ? 'y' : 'ies'}`; for (const a of list.slice(0, 400)) { results.append( el( 'a', { class: 'search__row search__row--ability', href: `#/ability/${a.id}` }, el('span', { class: 'search__name' }, a.name.replace(/-/g, ' ')), el('span', { class: 'search__cat' }, `Gen ${a.generation}`), el('span', { class: 'search__cat' }, `${a.pokemon.length} 🐾`), el('span', { class: 'search__ab-eff' }, a.effect || '—'), ), ); } } function run() { const q = loose(query); clear(results); note.textContent = ''; if (tab === 'pokemon') { const st = settings.get(); note.textContent = `Every Pokémon, any game. Opens for ${ st.versionGroup === 'all' ? 'all games' : prettify(st.versionGroup) }.`; if (q) renderPokemon(q); return; } if (tab === 'moves') renderMoves(q); else if (tab === 'items') renderItems(q); else renderAbilities(q); } run(); requestAnimationFrame(() => window.scrollTo(0, ui.get().searchScroll || 0)); // Switching games (from the Games sheet) changes which TM/HM filters // apply and the numbers shown — refresh the filter row and results. let lastVg = settings.get().versionGroup; const offSettings = settings.subscribe((s) => { if (s.versionGroup === lastVg) return; lastVg = s.versionGroup; syncUI(); run(); }); onTeardown(view, () => { offSettings(); ui.set({ searchScroll: window.scrollY }); }); return view; }