Gen 8+ games (Sword/Shield, Scarlet/Violet, Legends Arceus) dropped HMs entirely, so "HMs only" there just zeroed the list with no way to see why. buildFilters now checks whether the selected game actually has any TM / HM machines and only renders the checkbox that applies; a filter left stuck on from a previous game is cleared (and the "by number" sort reverts) when it no longer has a control. SearchView also re-runs its filters + results when the version group changes, so switching games from the Games sheet updates it live. "TMs only" + "HMs only" together already show the union (verified: 58 in FRLG = 50 TMs + 8 HMs, ordered TM01…HM08). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
421 lines
14 KiB
JavaScript
421 lines
14 KiB
JavaScript
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;
|
||
}
|