diff --git a/README.md b/README.md index e027825..df40aff 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/styles/layout.css b/src/styles/layout.css index b39103f..b8b52a9 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -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; diff --git a/src/views/MoveDetail.js b/src/views/MoveDetail.js index f44ba63..2b22c84 100644 --- a/src/views/MoveDetail.js +++ b/src/views/MoveDetail.js @@ -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; }