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 /
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.
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"
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

View File

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

View File

@ -1,12 +1,15 @@
import { el, clear } from '../lib/dom.js';
import { getMove } from '../data/api.js';
import { loadSnapshot } from '../data/snapshot.js';
import { prettify } from '../data/pokedex-resolver.js';
import { settings } from '../store/settings.js';
import { machineNumber } from '../components/MovesList.js';
import { Sprite } from '../components/Sprite.js';
import { TypeChip } from '../components/TypeChip.js';
import { typeHex } from '../lib/type-color.js';
const DMG = { physical: 'Physical', special: 'Special', status: 'Status' };
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
const english = (entries, key) => {
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' });
view.append(el('div', { class: 'view--loading' }, 'Loading move…'));
const snap = await loadSnapshot();
let d;
try {
d = await getMove(id);
@ -64,6 +69,17 @@ export async function MoveDetail(id) {
.join(', ');
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(
'dl',
{ class: 'pfacts' },
@ -72,7 +88,7 @@ export async function MoveDetail(id) {
fact('PP', d.pp ?? '—'),
fact('Priority', d.priority ?? 0),
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) {
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(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/search' }, ' Lookup')),
el(
@ -109,6 +175,7 @@ export async function MoveDetail(id) {
el('p', {}, effect),
bits.length ? el('p', { class: 'movebody__bits' }, bits.join(' · ')) : null,
),
learnersSection,
);
return view;
}