diff --git a/README.md b/README.md index e855fb2..e027825 100644 --- a/README.md +++ b/README.md @@ -97,11 +97,12 @@ Working app with a type-themed UI: applies Gen 1 / pre-Gen 6 rules. - **Games** — overlay picker with stylised version-colour cover tiles, sub-dex switch, and an "All games" (National, no gen limits) option. -- **Search** — tabbed lookup: Pokémon, Moves and Items, all browsable - offline from the snapshot. Moves filter by type / damage class / "TMs in - this game" and sort by power / accuracy / recency; Items filter by - category. Each has its own detail page. Query, scroll, tab and filters - persist. +- **Search** — tabbed lookup: Pokémon, Moves, Items and Abilities, all + browsable offline from the snapshot. Moves filter by type / damage class + / "TMs only" / "HMs only" for the selected game and sort by power / + accuracy / recency — or, with a machine filter on, by TM/HM number + (numbers are baked into the snapshot). Items filter by category. Each + has its own detail page. Query, scroll, tab and filters persist. - **Team** — a lineup of up to 6. Coverage leads with a "weak spots" summary (types that hit 2+ members, or that someone's weak to and nobody resists), then a full matchup grid with a diverging resist◀│▶weak bar per diff --git a/scripts/build-snapshot.mjs b/scripts/build-snapshot.mjs index a871784..8ac3179 100644 --- a/scripts/build-snapshot.mjs +++ b/scripts/build-snapshot.mjs @@ -335,7 +335,7 @@ async function main() { pp: m.pp, priority: m.priority, generation: idFromUrl(m.generation.url), - // version-group -> machine id (resolve to TM/HM number on demand) + // version-group -> machine id; `tm` label filled in by the pass below machines: (m.machines || []).map((x) => ({ vg: x.version_group.name, id: idFromUrl(x.machine.url), @@ -344,6 +344,29 @@ async function main() { }); moves.sort((a, b) => a.id - b.id); + // ---- TM / HM / TR numbers --------------------------------------- + // Resolve every machine id to its item name ("tm25" -> "TM25") so the + // search UI can show and sort by TM number without ~2000 lazy lookups. + const machineIds = [...new Set(moves.flatMap((m) => m.machines.map((x) => x.id)))]; + console.log(`Fetching ${machineIds.length} TM/HM numbers …`); + const tmById = new Map(); + await mapLimit(machineIds, CONCURRENCY, async (mid) => { + try { + const mc = await api(`machine/${mid}`); + const raw = mc.item?.name || ''; + const mm = raw.match(/^([a-z]+)0*(\d+)$/i); + tmById.set( + mid, + mm ? `${mm[1].toUpperCase()}${String(Number(mm[2])).padStart(2, '0')}` : raw.toUpperCase(), + ); + } catch { + /* leave unresolved */ + } + }); + for (const m of moves) { + for (const x of m.machines) x.tm = tmById.get(x.id) || null; + } + // ---- Abilities index ------------------------------------------ const abilityIndex = await api('ability?limit=100000'); console.log(`Fetching ${abilityIndex.results.length} abilities …`); diff --git a/src/store/ui.js b/src/store/ui.js index c536b3b..16d38cf 100644 --- a/src/store/ui.js +++ b/src/store/ui.js @@ -26,6 +26,7 @@ export const ui = createStore('pdx.ui', { mvClass: '', mvSort: 'name', mvTm: false, + mvHm: false, itCat: '', itSort: 'name', abSort: 'name', diff --git a/src/views/SearchView.js b/src/views/SearchView.js index 534208d..962ea47 100644 --- a/src/views/SearchView.js +++ b/src/views/SearchView.js @@ -4,7 +4,6 @@ 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 { getMachine, mapLimit } from '../data/api.js'; import { Sprite } from '../components/Sprite.js'; import { TypeChip } from '../components/TypeChip.js'; @@ -25,9 +24,12 @@ const TABS = [ { id: 'abilities', label: 'Abilities' }, ]; -const tmLabel = (raw) => { - const m = raw.match(/^([a-z]+)0*(\d+)$/i); - return m ? `${m[1].toUpperCase()}${String(Number(m[2])).padStart(2, '0')}` : raw.toUpperCase(); +/** 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() { @@ -36,8 +38,6 @@ export async function SearchView() { let tab = TABS.some((t) => t.id === ui.get().searchTab) ? ui.get().searchTab : 'pokemon'; let query = ui.get().searchQuery || ''; - let enrichTimer = null; - let runToken = 0; const input = el('input', { class: 'search__input', @@ -85,6 +85,29 @@ export async function SearchView() { ); } + // "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') { @@ -104,26 +127,22 @@ export async function SearchView() { ), pill( 'mvSort', - [['name', 'A–Z'], ['power', 'Power'], ['accuracy', 'Accuracy'], ['gen', 'Newest']], + [ + ['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(); }, ), - el( - 'label', - { class: 'lookup__check' }, - el('input', { - type: 'checkbox', - checked: !!ui.get().mvTm, - onchange: (e) => { - ui.set({ mvTm: e.target.checked }); - run(); - }, - }), - 'TMs only', - ), + machineToggle('mvTm', 'TMs only'), + machineToggle('mvHm', 'HMs only'), ); } else if (tab === 'items') { filters.append( @@ -205,19 +224,41 @@ export async function SearchView() { } // ---- Moves (from snapshot, offline) ----------------------------- - function renderMoves(q, token) { + 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 (tmOnly && !m.machines.some((x) => vg === 'all' || x.vg === vg)) 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; }); @@ -225,16 +266,22 @@ export async function SearchView() { 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); - const machineCells = new Map(); for (const m of shown) { - const tmCell = tmOnly ? el('span', { class: 'moverow__tm' }) : null; - if (tmCell) machineCells.set(m.id, { cell: tmCell, m }); + const tmCell = machineFilter + ? el('span', { class: 'moverow__tm' }, machineFor(m)?.tm || '') + : null; results.append( el( 'a', @@ -254,23 +301,6 @@ export async function SearchView() { if (list.length > shown.length) { results.append(el('p', { class: 'search__empty' }, `Showing ${shown.length} of ${list.length} — refine to see more.`)); } - - if (tmOnly && machineCells.size) { - clearTimeout(enrichTimer); - enrichTimer = setTimeout(() => { - mapLimit([...machineCells.values()], 6, async ({ cell, m }) => { - if (token !== runToken || !cell.isConnected) return; - const hit = m.machines.find((x) => x.vg === vg) || m.machines[m.machines.length - 1]; - if (!hit) return; - try { - const mc = await getMachine(hit.id); - cell.textContent = tmLabel(mc.item.name); - } catch { - /* leave blank */ - } - }); - }, 200); - } } // ---- Items (from snapshot, offline) --------------------------- @@ -331,7 +361,6 @@ export async function SearchView() { } function run() { - const token = ++runToken; const q = loose(query); clear(results); note.textContent = ''; @@ -343,7 +372,7 @@ export async function SearchView() { if (q) renderPokemon(q); return; } - if (tab === 'moves') renderMoves(q, token); + if (tab === 'moves') renderMoves(q); else if (tab === 'items') renderItems(q); else renderAbilities(q); } @@ -351,7 +380,6 @@ export async function SearchView() { run(); requestAnimationFrame(() => window.scrollTo(0, ui.get().searchScroll || 0)); onTeardown(view, () => { - clearTimeout(enrichTimer); ui.set({ searchScroll: window.scrollY }); });