Compare commits

..

4 Commits

Author SHA1 Message Date
86e9bc60bc Move detail: list the Pokémon that can learn the move
The "Learned by N Pokémon" fact was a dead number. Now the move page
renders that list as a clickable sprite grid (links to each Pokémon),
with a filter box once it's more than ~24 long.

PokéAPI's learned_by_pokemon is flat across all games, so for a specific
game it's narrowed to species that existed by that generation — a
"Crystal" view of Surf shows 69 Gen 1–2 Pokémon, not the full 231. Not
perfectly game-exact (a mon that only gained the move in a later gen can
slip through) but far better than guess-and-check. National ("all") shows
the unfiltered list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-09 17:34:41 -04:00
3193b167ac Search moves: hide "HMs only" for games without HMs
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
2026-09-08 12:26:52 -04:00
fa302af7f1 Search moves: sort by TM/HM number; add an "HMs only" filter
TM/HM numbers are now baked into the snapshot (build-snapshot resolves
every machine id -> "TM25" etc. in a post-pass, ~2400 lookups), so the
Search moves list no longer lazily fetches /machine per row — it shows
numbers synchronously and can sort by them.

- New "TM / HM number" sort option, shown only while a machine filter is
  on, and dropped back to A–Z when neither is.
- "TMs only" now means strictly TMs; added a parallel "HMs only". Either
  or both. In "All games" mode a move can be a TM in one game and an HM
  in another, so machineFor picks the newest entry of a requested kind
  rather than just the newest entry (fixes "HMs only" showing 0).

Removes the getMachine/mapLimit lazy-resolution path (and its now-unused
stale-token guard) from SearchView.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-08 10:44:14 -04:00
d73438dd29 MovesList: order the TM/HM group by TM number
The learnset's "By TM / HM" group was alphabetical. TM numbers aren't in
the snapshot — they resolve lazily per move via /machine/{id}, which the
group's enrichers already fetch on expand. So: await those resolutions
(fillTm is now async and stashes the label on the move), and once the
whole group is enriched, re-append the rows in TM/HM/TR-then-number order
— TM01, TM02 … HM01 …, matching the in-game list. Verified against
Charizard in FireRed/LeafGreen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-08 10:28:27 -04:00
7 changed files with 258 additions and 79 deletions

View File

@ -91,16 +91,20 @@ Working app with a type-themed UI:
Moves / Locations); coloured animated stat bars; defensive type matchups; Moves / Locations); coloured animated stat bars; defensive type matchups;
evolution chain re-parented to the selected game's generation; learnset with evolution chain re-parented to the selected game's generation; learnset with
per-move power/type/accuracy/PP/effect (era-accurate via `past_values`, per-move power/type/accuracy/PP/effect (era-accurate via `past_values`,
incl. pre-Gen-4 physical/special-by-type); wild encounter locations. incl. pre-Gen-4 physical/special-by-type), the TM/HM group ordered by
TM number for the selected game; wild encounter locations.
- **Game-aware** — abilities gated to Gen 3+ (hidden to Gen 5+); type chart - **Game-aware** — abilities gated to Gen 3+ (hidden to Gen 5+); type chart
applies Gen 1 / pre-Gen 6 rules. applies Gen 1 / pre-Gen 6 rules.
- **Games** — overlay picker with stylised version-colour cover tiles, - **Games** — overlay picker with stylised version-colour cover tiles,
sub-dex switch, and an "All games" (National, no gen limits) option. sub-dex switch, and an "All games" (National, no gen limits) option.
- **Search** — tabbed lookup: Pokémon, Moves and Items, all browsable - **Search** — tabbed lookup: Pokémon, Moves, Items and Abilities, all
offline from the snapshot. Moves filter by type / damage class / "TMs in browsable offline from the snapshot. Moves filter by type / damage class
this game" and sort by power / accuracy / recency; Items filter by / "TMs only" / "HMs only" for the selected game and sort by power /
category. Each has its own detail page. Query, scroll, tab and filters accuracy / recency — or, with a machine filter on, by TM/HM number
persist. (numbers are baked into the snapshot). Items filter by category. Each
has its own detail page — the move page lists every Pokémon that can
learn it (filterable, narrowed to species that existed by the selected
game's generation). Query, scroll, tab and filters persist.
- **Team** — a lineup of up to 6. Coverage leads with a "weak spots" - **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 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 resists), then a full matchup grid with a diverging resist◀│▶weak bar per

View File

@ -335,7 +335,7 @@ async function main() {
pp: m.pp, pp: m.pp,
priority: m.priority, priority: m.priority,
generation: idFromUrl(m.generation.url), 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) => ({ machines: (m.machines || []).map((x) => ({
vg: x.version_group.name, vg: x.version_group.name,
id: idFromUrl(x.machine.url), id: idFromUrl(x.machine.url),
@ -344,6 +344,29 @@ async function main() {
}); });
moves.sort((a, b) => a.id - b.id); 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 ------------------------------------------ // ---- Abilities index ------------------------------------------
const abilityIndex = await api('ability?limit=100000'); const abilityIndex = await api('ability?limit=100000');
console.log(`Fetching ${abilityIndex.results.length} abilities …`); console.log(`Fetching ${abilityIndex.results.length} abilities …`);

View File

@ -27,6 +27,15 @@ const METHOD_LABEL = {
'form-change': 'On form change', 'form-change': 'On form change',
}; };
const METHOD_ORDER = ['level-up', 'egg', 'machine', 'tutor', 'form-change']; const METHOD_ORDER = ['level-up', 'egg', 'machine', 'tutor', 'form-change'];
/** Numeric sort key from a TM/HM/TR label like "TM25": TMs, then HMs, then TRs. */
function tmSortKey(label) {
if (!label) return 1e9; // not resolved yet -> sink to the bottom
const m = String(label).match(/^([A-Z]+)0*(\d+)$/);
if (!m) return 1e9 - 1;
const rank = { TM: 0, HM: 1, TR: 2 }[m[1]] ?? 3;
return rank * 1000 + Number(m[2]);
}
const DAMAGE_CLASS = { physical: 'Phys', special: 'Spec', status: 'Stat' }; const DAMAGE_CLASS = { physical: 'Phys', special: 'Spec', status: 'Stat' };
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop()); const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
@ -141,11 +150,12 @@ export function MovesList(pokemonMoves, { versionGroupKey, gen = 9, genOfVg = ()
const tmCell = method === 'machine' ? el('span', { class: 'moverow__tm' }) : null; const tmCell = method === 'machine' ? el('span', { class: 'moverow__tm' }) : null;
let bodyFilled = false; let bodyFilled = false;
const fillTm = () => { const fillTm = async () => {
if (tmCell && !tmCell.textContent && m.data) { if (!tmCell || m._tm != null || !m.data) return;
machineNumber(m.data, versionGroupKey).then((n) => { const n = await machineNumber(m.data, versionGroupKey);
if (n) tmCell.textContent = n; if (n) {
}); m._tm = n;
tmCell.textContent = n;
} }
}; };
@ -167,7 +177,7 @@ export function MovesList(pokemonMoves, { versionGroupKey, gen = 9, genOfVg = ()
const v = forGeneration(m.data, gen, genOfVg); const v = forGeneration(m.data, gen, genOfVg);
fillMeta(meta, v); fillMeta(meta, v);
fillBody(body, m.data, v); fillBody(body, m.data, v);
fillTm(); await fillTm();
} catch { } catch {
body.replaceChildren(el('span', { class: 'moverow__pending' }, 'Details unavailable.')); body.replaceChildren(el('span', { class: 'moverow__pending' }, 'Details unavailable.'));
} }
@ -182,13 +192,15 @@ export function MovesList(pokemonMoves, { versionGroupKey, gen = 9, genOfVg = ()
el('span', { class: 'moverow__chev', 'aria-hidden': 'true' }, '▾'), el('span', { class: 'moverow__chev', 'aria-hidden': 'true' }, '▾'),
); );
list.append(el('div', { class: 'movecard' }, row, body)); const card = el('div', { class: 'movecard' }, row, body);
m._card = card;
list.append(card);
enrichers.push(async () => { enrichers.push(async () => {
try { try {
m.data = m.data || (await getMove(m.id)); m.data = m.data || (await getMove(m.id));
fillMeta(meta, forGeneration(m.data, gen, genOfVg)); fillMeta(meta, forGeneration(m.data, gen, genOfVg));
fillTm(); await fillTm();
} catch { } catch {
meta.replaceChildren(el('span', { class: 'moverow__pending' }, '—')); meta.replaceChildren(el('span', { class: 'moverow__pending' }, '—'));
} }
@ -212,6 +224,14 @@ export function MovesList(pokemonMoves, { versionGroupKey, gen = 9, genOfVg = ()
if (enriched) return; if (enriched) return;
enriched = true; enriched = true;
await mapLimit(enrichers, 6, (fn) => fn()); await mapLimit(enrichers, 6, (fn) => fn());
// TM/HM moves land in whatever order the API returned them; reorder
// to TM01, TM02 … once every number has resolved.
if (method === 'machine') {
moves
.slice()
.sort((a, b) => tmSortKey(a._tm) - tmSortKey(b._tm) || a.name.localeCompare(b.name))
.forEach((mm) => list.append(mm._card));
}
}; };
group.addEventListener('toggle', () => group.open && runEnrich()); group.addEventListener('toggle', () => group.open && runEnrich());
if (method === 'level-up') runEnrich(); if (method === 'level-up') runEnrich();

View File

@ -26,6 +26,7 @@ export const ui = createStore('pdx.ui', {
mvClass: '', mvClass: '',
mvSort: 'name', mvSort: 'name',
mvTm: false, mvTm: false,
mvHm: false,
itCat: '', itCat: '',
itSort: 'name', itSort: 'name',
abSort: 'name', abSort: 'name',

View File

@ -2700,6 +2700,9 @@
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
gap: 8px; gap: 8px;
} }
.learners__filter {
margin-bottom: 10px;
}
.ab-mon { .ab-mon {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -1,12 +1,15 @@
import { el, clear } from '../lib/dom.js'; import { el, clear } from '../lib/dom.js';
import { getMove } from '../data/api.js'; import { getMove } from '../data/api.js';
import { loadSnapshot } from '../data/snapshot.js';
import { prettify } from '../data/pokedex-resolver.js'; import { prettify } from '../data/pokedex-resolver.js';
import { settings } from '../store/settings.js'; import { settings } from '../store/settings.js';
import { machineNumber } from '../components/MovesList.js'; import { machineNumber } from '../components/MovesList.js';
import { Sprite } from '../components/Sprite.js';
import { TypeChip } from '../components/TypeChip.js'; import { TypeChip } from '../components/TypeChip.js';
import { typeHex } from '../lib/type-color.js'; import { typeHex } from '../lib/type-color.js';
const DMG = { physical: 'Physical', special: 'Special', status: 'Status' }; const DMG = { physical: 'Physical', special: 'Special', status: 'Status' };
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
const english = (entries, key) => { const english = (entries, key) => {
const hit = (entries || []).find((e) => e.language.name === 'en'); const hit = (entries || []).find((e) => e.language.name === 'en');
@ -21,6 +24,8 @@ export async function MoveDetail(id) {
const view = el('section', { class: 'view lookup' }); const view = el('section', { class: 'view lookup' });
view.append(el('div', { class: 'view--loading' }, 'Loading move…')); view.append(el('div', { class: 'view--loading' }, 'Loading move…'));
const snap = await loadSnapshot();
let d; let d;
try { try {
d = await getMove(id); d = await getMove(id);
@ -64,6 +69,17 @@ export async function MoveDetail(id) {
.join(', '); .join(', ');
if (statChanges) bits.push(statChanges); if (statChanges) bits.push(statChanges);
// Pokémon that can learn this move. PokéAPI only gives a flat, all-games
// list — narrow it to species that existed by the selected game's
// generation so a "Crystal" view isn't full of Gen 9 Pokémon.
const vg = settings.get().versionGroup;
const gen = snap.versionGroupByKey.get(vg)?.generation ?? 9;
let learners = (d.learned_by_pokemon || [])
.map((p) => snap.speciesById.get(idFromUrl(p.url)))
.filter(Boolean);
if (vg !== 'all') learners = learners.filter((sp) => sp.generation <= gen);
learners.sort((a, b) => a.id - b.id);
const facts = el( const facts = el(
'dl', 'dl',
{ class: 'pfacts' }, { class: 'pfacts' },
@ -72,7 +88,7 @@ export async function MoveDetail(id) {
fact('PP', d.pp ?? '—'), fact('PP', d.pp ?? '—'),
fact('Priority', d.priority ?? 0), fact('Priority', d.priority ?? 0),
fact('Introduced', prettify(d.generation.name)), fact('Introduced', prettify(d.generation.name)),
fact('Learned by', `${d.learned_by_pokemon.length} Pokémon`), fact('Learned by', `${learners.length} Pokémon`),
); );
if (d.machines?.length) { if (d.machines?.length) {
const vg = settings.get().versionGroup; const vg = settings.get().versionGroup;
@ -88,6 +104,56 @@ export async function MoveDetail(id) {
}); });
} }
// ---- Learners grid (filterable) ----------------------------------
const style = settings.get().spriteStyle;
const LEARNER_CAP = 400;
const learnersGrid = el('div', { class: 'ab-mons' });
function paintLearners(q) {
const qq = (q || '').trim().toLowerCase().replace(/\s+/g, '-');
const shown = qq ? learners.filter((sp) => sp.name.includes(qq)) : learners;
clear(learnersGrid);
if (!shown.length) {
learnersGrid.append(el('p', { class: 'detail__muted' }, 'No matches.'));
return;
}
for (const sp of shown.slice(0, LEARNER_CAP)) {
learnersGrid.append(
el(
'a',
{ class: 'ab-mon', href: `#/pokemon/${sp.id}` },
Sprite(sp.id, { style, alt: sp.name, size: 48 }),
el('span', {}, sp.name.replace(/-/g, ' ')),
),
);
}
if (shown.length > LEARNER_CAP) {
learnersGrid.append(
el('p', { class: 'detail__muted' }, `+${shown.length - LEARNER_CAP} more — filter to narrow.`),
);
}
}
paintLearners('');
const learnerFilter = el('input', {
type: 'search',
class: 'search__input learners__filter',
placeholder: 'Filter Pokémon…',
oninput: (e) => paintLearners(e.target.value),
});
const learnersSection = learners.length
? el(
'section',
{ class: 'detail__section' },
el(
'h2',
{},
`Pokémon that can learn this${vg === 'all' ? '' : ` in ${prettify(vg)}`} (${learners.length})`,
),
learners.length > 24 ? learnerFilter : null,
learnersGrid,
)
: null;
clear(view).append( clear(view).append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/search' }, ' Lookup')), el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/search' }, ' Lookup')),
el( el(
@ -109,6 +175,7 @@ export async function MoveDetail(id) {
el('p', {}, effect), el('p', {}, effect),
bits.length ? el('p', { class: 'movebody__bits' }, bits.join(' · ')) : null, bits.length ? el('p', { class: 'movebody__bits' }, bits.join(' · ')) : null,
), ),
learnersSection,
); );
return view; return view;
} }

View File

@ -4,7 +4,6 @@ import { settings } from '../store/settings.js';
import { ui } from '../store/ui.js'; import { ui } from '../store/ui.js';
import { entry } from '../store/selection.js'; import { entry } from '../store/selection.js';
import { prettify } from '../data/pokedex-resolver.js'; import { prettify } from '../data/pokedex-resolver.js';
import { getMachine, mapLimit } from '../data/api.js';
import { Sprite } from '../components/Sprite.js'; import { Sprite } from '../components/Sprite.js';
import { TypeChip } from '../components/TypeChip.js'; import { TypeChip } from '../components/TypeChip.js';
@ -25,9 +24,12 @@ const TABS = [
{ id: 'abilities', label: 'Abilities' }, { id: 'abilities', label: 'Abilities' },
]; ];
const tmLabel = (raw) => { /** Numeric sort key from a TM/HM/TR label like "TM25": TMs, then HMs, then TRs. */
const m = raw.match(/^([a-z]+)0*(\d+)$/i); const tmSortKey = (label) => {
return m ? `${m[1].toUpperCase()}${String(Number(m[2])).padStart(2, '0')}` : raw.toUpperCase(); 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() { 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 tab = TABS.some((t) => t.id === ui.get().searchTab) ? ui.get().searchTab : 'pokemon';
let query = ui.get().searchQuery || ''; let query = ui.get().searchQuery || '';
let enrichTimer = null;
let runToken = 0;
const input = el('input', { const input = el('input', {
class: 'search__input', class: 'search__input',
@ -85,45 +85,85 @@ 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() { function buildFilters() {
clear(filters); clear(filters);
if (tab === 'moves') { 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( filters.append(
pill('mvType', [['', 'Any type'], ...TYPES.map((t) => [t, prettify(t)])], ui.get().mvType, (v) => { ...[
ui.set({ mvType: v }); pill('mvType', [['', 'Any type'], ...TYPES.map((t) => [t, prettify(t)])], ui.get().mvType, (v) => {
run(); ui.set({ mvType: v });
}),
pill(
'mvClass',
[['', 'Any category'], ['physical', 'Physical'], ['special', 'Special'], ['status', 'Status']],
ui.get().mvClass,
(v) => {
ui.set({ mvClass: v });
run(); run();
}, }),
), pill(
pill( 'mvClass',
'mvSort', [['', 'Any category'], ['physical', 'Physical'], ['special', 'Special'], ['status', 'Status']],
[['name', 'AZ'], ['power', 'Power'], ['accuracy', 'Accuracy'], ['gen', 'Newest']], ui.get().mvClass,
ui.get().mvSort, (v) => {
(v) => { ui.set({ mvClass: 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(); run();
}, },
}), ),
'TMs only', pill(
), 'mvSort',
[
['name', 'AZ'],
['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') { } else if (tab === 'items') {
filters.append( filters.append(
@ -205,19 +245,41 @@ export async function SearchView() {
} }
// ---- Moves (from snapshot, offline) ----------------------------- // ---- Moves (from snapshot, offline) -----------------------------
function renderMoves(q, token) { function renderMoves(q) {
const st = settings.get(); const st = settings.get();
const vg = st.versionGroup; const vg = st.versionGroup;
const type = ui.get().mvType; const type = ui.get().mvType;
const cls = ui.get().mvClass; const cls = ui.get().mvClass;
const sort = ui.get().mvSort; const sort = ui.get().mvSort;
const tmOnly = !!ui.get().mvTm; 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) => { let list = snap.moves.filter((m) => {
if (q && !loose(m.name).includes(q)) return false; if (q && !loose(m.name).includes(q)) return false;
if (type && m.type !== type) return false; if (type && m.type !== type) return false;
if (cls && m.damageClass !== cls) 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; return true;
}); });
@ -225,16 +287,22 @@ export async function SearchView() {
if (sort === 'power') return (b.power ?? -1) - (a.power ?? -1) || a.name.localeCompare(b.name); 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 === '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 === '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); return a.name.localeCompare(b.name);
}); });
note.textContent = `${list.length} move${list.length === 1 ? '' : 's'}`; note.textContent = `${list.length} move${list.length === 1 ? '' : 's'}`;
const shown = list.slice(0, 400); const shown = list.slice(0, 400);
const machineCells = new Map();
for (const m of shown) { for (const m of shown) {
const tmCell = tmOnly ? el('span', { class: 'moverow__tm' }) : null; const tmCell = machineFilter
if (tmCell) machineCells.set(m.id, { cell: tmCell, m }); ? el('span', { class: 'moverow__tm' }, machineFor(m)?.tm || '')
: null;
results.append( results.append(
el( el(
'a', 'a',
@ -254,23 +322,6 @@ export async function SearchView() {
if (list.length > shown.length) { if (list.length > shown.length) {
results.append(el('p', { class: 'search__empty' }, `Showing ${shown.length} of ${list.length} — refine to see more.`)); 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) --------------------------- // ---- Items (from snapshot, offline) ---------------------------
@ -331,7 +382,6 @@ export async function SearchView() {
} }
function run() { function run() {
const token = ++runToken;
const q = loose(query); const q = loose(query);
clear(results); clear(results);
note.textContent = ''; note.textContent = '';
@ -343,15 +393,26 @@ export async function SearchView() {
if (q) renderPokemon(q); if (q) renderPokemon(q);
return; return;
} }
if (tab === 'moves') renderMoves(q, token); if (tab === 'moves') renderMoves(q);
else if (tab === 'items') renderItems(q); else if (tab === 'items') renderItems(q);
else renderAbilities(q); else renderAbilities(q);
} }
run(); run();
requestAnimationFrame(() => window.scrollTo(0, ui.get().searchScroll || 0)); 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, () => { onTeardown(view, () => {
clearTimeout(enrichTimer); offSettings();
ui.set({ searchScroll: window.scrollY }); ui.set({ searchScroll: window.scrollY });
}); });