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
This commit is contained in:
chris 2026-09-09 17:34:41 -04:00
parent 3193b167ac
commit 86e9bc60bc
3 changed files with 74 additions and 2 deletions

View File

@ -102,7 +102,9 @@ Working app with a type-themed UI:
/ "TMs only" / "HMs only" for the selected game and sort by power / / "TMs only" / "HMs only" for the selected game and sort by power /
accuracy / recency — or, with a machine filter on, by TM/HM number accuracy / recency — or, with a machine filter on, by TM/HM number
(numbers are baked into the snapshot). Items filter by category. Each (numbers are baked into the snapshot). Items filter by category. Each
has its own detail page. Query, scroll, tab and filters persist. 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

@ -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;
} }