From 415cf3edecc4babe0d2aaeaaa49dfcc358b48db6 Mon Sep 17 00:00:00 2001 From: chris Date: Fri, 28 Aug 2026 11:00:14 -0400 Subject: [PATCH] Abilities lookup + detail; Natures reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Snapshot bakes an abilities index (373: name, generation, effect, and the species that have it). Snapshot ~872 KB / 133 KB gzip. - Search gains an 'Abilities' tab — browse all 373 offline, filter/search by name or effect, sort A–Z / most-Pokemon / newest - #/ability/:id detail: effect + a grid of every Pokemon with the ability (all from the snapshot, no fetch) - Pokemon detail: the Abilities line is now tappable, linking to each ability - #/natures: the full 25-nature +10%/-10% table; linked from the Team view Co-Authored-By: Claude Sonnet 5 --- scripts/build-snapshot.mjs | 27 ++++++++++++ src/data/natures.js | 39 +++++++++++++++++ src/data/snapshot.js | 2 + src/router.js | 4 ++ src/store/ui.js | 1 + src/styles/layout.css | 85 ++++++++++++++++++++++++++++++++++++++ src/views/AbilityDetail.js | 66 +++++++++++++++++++++++++++++ src/views/NaturesView.js | 36 ++++++++++++++++ src/views/PokemonDetail.js | 18 ++++++-- src/views/SearchView.js | 50 +++++++++++++++++++++- src/views/TeamView.js | 7 +++- 11 files changed, 328 insertions(+), 7 deletions(-) create mode 100644 src/data/natures.js create mode 100644 src/views/AbilityDetail.js create mode 100644 src/views/NaturesView.js diff --git a/scripts/build-snapshot.mjs b/scripts/build-snapshot.mjs index c22ff68..7bee999 100644 --- a/scripts/build-snapshot.mjs +++ b/scripts/build-snapshot.mjs @@ -220,6 +220,31 @@ async function main() { }); moves.sort((a, b) => a.id - b.id); + // ---- Abilities index ------------------------------------------ + const abilityIndex = await api('ability?limit=100000'); + console.log(`Fetching ${abilityIndex.results.length} abilities …`); + const abilities = await mapLimit(abilityIndex.results, CONCURRENCY, async (r) => { + const a = await api(r.url); + const en = (a.effect_entries || []).find((e) => e.language.name === 'en'); + const flavour = [...(a.flavor_text_entries || [])] + .reverse() + .find((e) => e.language.name === 'en'); + return { + id: a.id, + name: a.name, + generation: idFromUrl(a.generation.url), + isMainSeries: a.is_main_series, + effect: + (en && (en.short_effect || en.effect)) || + (flavour && flavour.flavor_text) || + '', + pokemon: [ + ...new Set(a.pokemon.map((x) => idFromUrl(x.pokemon.url)).filter((n) => n <= 100000)), + ], + }; + }); + abilities.sort((a, b) => a.id - b.id); + // ---- Item categories (gives every item a category cheaply) ---- const catIndex = await api('item-category?limit=100'); const items = []; @@ -240,6 +265,7 @@ async function main() { species: species.length, moves: moves.length, items: items.length, + abilities: abilities.length, pokedexes: pokedexes.length, versionGroups: versionGroups.length, }, @@ -249,6 +275,7 @@ async function main() { species, moves, items, + abilities, pokedexes, versionGroups, }; diff --git a/src/data/natures.js b/src/data/natures.js new file mode 100644 index 0000000..8d4a43f --- /dev/null +++ b/src/data/natures.js @@ -0,0 +1,39 @@ +/** + * The 25 natures. Each raises one stat by 10% and lowers another by 10%; + * the five where up === down are neutral. HP is never affected. + */ +export const STAT_LABEL = { + atk: 'Attack', + def: 'Defense', + spa: 'Sp. Atk', + spd: 'Sp. Def', + spe: 'Speed', +}; + +export const NATURES = [ + { name: 'Hardy', up: 'atk', down: 'atk' }, + { name: 'Lonely', up: 'atk', down: 'def' }, + { name: 'Brave', up: 'atk', down: 'spe' }, + { name: 'Adamant', up: 'atk', down: 'spa' }, + { name: 'Naughty', up: 'atk', down: 'spd' }, + { name: 'Bold', up: 'def', down: 'atk' }, + { name: 'Docile', up: 'def', down: 'def' }, + { name: 'Relaxed', up: 'def', down: 'spe' }, + { name: 'Impish', up: 'def', down: 'spa' }, + { name: 'Lax', up: 'def', down: 'spd' }, + { name: 'Timid', up: 'spe', down: 'atk' }, + { name: 'Hasty', up: 'spe', down: 'def' }, + { name: 'Serious', up: 'spe', down: 'spe' }, + { name: 'Jolly', up: 'spe', down: 'spa' }, + { name: 'Naive', up: 'spe', down: 'spd' }, + { name: 'Modest', up: 'spa', down: 'atk' }, + { name: 'Mild', up: 'spa', down: 'def' }, + { name: 'Quiet', up: 'spa', down: 'spe' }, + { name: 'Bashful', up: 'spa', down: 'spa' }, + { name: 'Rash', up: 'spa', down: 'spd' }, + { name: 'Calm', up: 'spd', down: 'atk' }, + { name: 'Gentle', up: 'spd', down: 'def' }, + { name: 'Sassy', up: 'spd', down: 'spe' }, + { name: 'Careful', up: 'spd', down: 'spa' }, + { name: 'Quirky', up: 'spd', down: 'spd' }, +]; diff --git a/src/data/snapshot.js b/src/data/snapshot.js index 3aef9b2..1b4a713 100644 --- a/src/data/snapshot.js +++ b/src/data/snapshot.js @@ -22,8 +22,10 @@ export async function loadSnapshot() { data.generationById = new Map(data.generations.map((g) => [g.id, g])); data.moves = data.moves || []; data.items = data.items || []; + data.abilities = data.abilities || []; data.moveById = new Map(data.moves.map((m) => [m.id, m])); data.itemById = new Map(data.items.map((it) => [it.id, it])); + data.abilityById = new Map(data.abilities.map((a) => [a.id, a])); data.itemCategories = [...new Set(data.items.map((it) => it.category))].sort(); cached = data; diff --git a/src/router.js b/src/router.js index 8e88137..22f1a07 100644 --- a/src/router.js +++ b/src/router.js @@ -5,7 +5,9 @@ import { SearchView } from './views/SearchView.js'; import { SettingsView } from './views/SettingsView.js'; import { MoveDetail } from './views/MoveDetail.js'; import { ItemDetail } from './views/ItemDetail.js'; +import { AbilityDetail } from './views/AbilityDetail.js'; import { TeamView } from './views/TeamView.js'; +import { NaturesView } from './views/NaturesView.js'; import { detailSkeleton, lookupSkeleton } from './components/skeletons.js'; const routes = [ @@ -13,7 +15,9 @@ const routes = [ { pattern: /^#\/pokemon\/(\d+)$/, view: (m) => PokemonDetail(Number(m[1])), skeleton: detailSkeleton }, { pattern: /^#\/move\/(\d+)$/, view: (m) => MoveDetail(Number(m[1])), skeleton: lookupSkeleton }, { pattern: /^#\/item\/(\d+)$/, view: (m) => ItemDetail(Number(m[1])), skeleton: lookupSkeleton }, + { pattern: /^#\/ability\/(\d+)$/, view: (m) => AbilityDetail(Number(m[1])), skeleton: lookupSkeleton }, { pattern: /^#\/team$/, view: () => TeamView() }, + { pattern: /^#\/natures$/, view: () => NaturesView() }, { pattern: /^#\/search$/, view: () => SearchView() }, { pattern: /^#\/settings$/, view: () => SettingsView() }, ]; diff --git a/src/store/ui.js b/src/store/ui.js index 46e889f..2010657 100644 --- a/src/store/ui.js +++ b/src/store/ui.js @@ -24,4 +24,5 @@ export const ui = createStore('pdx.ui', { mvTm: false, itCat: '', itSort: 'name', + abSort: 'name', }); diff --git a/src/styles/layout.css b/src/styles/layout.css index aedbe7f..3212959 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -2305,3 +2305,88 @@ border-radius: 6px; text-transform: capitalize; } + +/* ---- Abilities ---------------------------------------------- */ +.search__row--ability { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + padding: 11px 14px; +} +.search__ab-eff { + flex-basis: 100%; + font-size: 0.78rem; + color: var(--text-dim); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ab-mons { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(88px, 1fr)); + gap: 8px; +} +.ab-mon { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 8px 4px; + border-radius: var(--radius-sm); + background: var(--surface-2); + text-decoration: none; + color: inherit; + font-size: 0.72rem; + font-weight: 600; + text-transform: capitalize; + text-align: center; +} +.ab-mon:hover { + background: color-mix(in srgb, var(--accent) 18%, var(--surface-2)); +} +.ab-mon .sprite { + width: 48px; + height: 48px; + object-fit: contain; +} + +/* ---- Natures table --------------------------------------- */ +.natures { + display: flex; + flex-direction: column; +} +.natures__row { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; + padding: 9px 6px; + border-bottom: 1px solid var(--border); + font-size: 0.88rem; +} +.natures__row--head { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-dim); +} +.natures__name { + font-weight: 700; +} +.natures__up { + color: var(--good); + font-weight: 600; +} +.natures__down { + color: var(--danger); + font-weight: 600; +} +.natures__row.is-neutral { + color: var(--text-dim); +} +.natures__row.is-neutral .natures__up, +.natures__row.is-neutral .natures__down { + color: var(--text-dim); + font-weight: 400; +} diff --git a/src/views/AbilityDetail.js b/src/views/AbilityDetail.js new file mode 100644 index 0000000..2204f9c --- /dev/null +++ b/src/views/AbilityDetail.js @@ -0,0 +1,66 @@ +import { el, clear } from '../lib/dom.js'; +import { loadSnapshot } from '../data/snapshot.js'; +import { settings } from '../store/settings.js'; +import { Sprite } from '../components/Sprite.js'; + +export async function AbilityDetail(id) { + const view = el('section', { class: 'view lookup' }); + const snap = await loadSnapshot(); + const a = snap.abilityById.get(id); + + if (!a) { + view.append( + el( + 'div', + { class: 'view--error' }, + el('h1', {}, 'Unknown ability'), + el('a', { class: 'button', href: '#/search' }, 'Back to lookup'), + ), + ); + return view; + } + + const style = settings.get().spriteStyle; + const mons = a.pokemon.map((pid) => snap.speciesById.get(pid)).filter(Boolean); + mons.sort((x, y) => x.id - y.id); + + clear(view).append( + el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/search' }, '‹ Lookup')), + el( + 'header', + { class: 'lookup__head lookup__head--item' }, + el( + 'div', + {}, + el('h1', {}, a.name.replace(/-/g, ' ')), + el('span', { class: 'lookup__class' }, `Introduced Gen ${a.generation}`), + ), + ), + el( + 'section', + { class: 'detail__section' }, + el('h2', {}, 'Effect'), + el('p', {}, a.effect || 'No description available.'), + ), + el( + 'section', + { class: 'detail__section' }, + el('h2', {}, `Pokémon with this ability (${mons.length})`), + mons.length + ? el( + 'div', + { class: 'ab-mons' }, + ...mons.map((sp) => + el( + 'a', + { class: 'ab-mon', href: `#/pokemon/${sp.id}` }, + Sprite(sp.id, { style, alt: sp.name, size: 48 }), + el('span', {}, sp.name.replace(/-/g, ' ')), + ), + ), + ) + : el('p', { class: 'detail__muted' }, 'None on record.'), + ), + ); + return view; +} diff --git a/src/views/NaturesView.js b/src/views/NaturesView.js new file mode 100644 index 0000000..f52305b --- /dev/null +++ b/src/views/NaturesView.js @@ -0,0 +1,36 @@ +import { el } from '../lib/dom.js'; +import { NATURES, STAT_LABEL } from '../data/natures.js'; + +export function NaturesView() { + const view = el('section', { class: 'view lookup' }); + view.append( + el( + 'header', + { class: 'view__header' }, + el('h1', {}, 'Natures'), + el('p', {}, 'Each nature raises one stat by 10% and lowers another by 10%. HP is never affected; five natures are neutral.'), + ), + el( + 'div', + { class: 'natures' }, + el( + 'div', + { class: 'natures__row natures__row--head' }, + el('span', {}, 'Nature'), + el('span', { class: 'natures__up' }, '▲ raises'), + el('span', { class: 'natures__down' }, '▼ lowers'), + ), + ...NATURES.map((n) => { + const neutral = n.up === n.down; + return el( + 'div', + { class: `natures__row${neutral ? ' is-neutral' : ''}` }, + el('span', { class: 'natures__name' }, n.name), + el('span', { class: 'natures__up' }, neutral ? '—' : STAT_LABEL[n.up]), + el('span', { class: 'natures__down' }, neutral ? '—' : STAT_LABEL[n.down]), + ); + }), + ), + ); + return view; +} diff --git a/src/views/PokemonDetail.js b/src/views/PokemonDetail.js index 4b88957..7a1587c 100644 --- a/src/views/PokemonDetail.js +++ b/src/views/PokemonDetail.js @@ -242,9 +242,19 @@ export async function PokemonDetail(nationalId) { syncTrack(); // ---- About panel ----------------------------------------------- - const abilities = pokemon.abilities - .filter((a) => !a.is_hidden || vgGen >= 5) - .map((a) => prettify(a.ability.name) + (a.is_hidden ? ' (hidden)' : '')); + const abilityList = pokemon.abilities.filter((a) => !a.is_hidden || vgGen >= 5); + const abilitiesNode = el( + 'span', + {}, + ...abilityList.flatMap((a, i) => { + const link = el( + 'a', + { class: 'link', href: `#/ability/${idFromUrl(a.ability.url)}` }, + prettify(a.ability.name) + (a.is_hidden ? ' (hidden)' : ''), + ); + return i === 0 ? [link] : [', ', link]; + }), + ); const evYield = pokemon.stats @@ -267,7 +277,7 @@ export async function PokemonDetail(nationalId) { { class: 'pfacts' }, fact('Height', `${(pokemon.height / 10).toFixed(1)} m`), fact('Weight', `${(pokemon.weight / 10).toFixed(1)} kg`), - abilities.length && vgGen >= 3 ? fact('Abilities', abilities.join(', ')) : null, + abilityList.length && vgGen >= 3 ? fact('Abilities', abilitiesNode) : null, fact('Introduced', prettify(species.generation.name)), species.genera?.length ? fact( diff --git a/src/views/SearchView.js b/src/views/SearchView.js index 4aa5c50..534208d 100644 --- a/src/views/SearchView.js +++ b/src/views/SearchView.js @@ -22,6 +22,7 @@ const TABS = [ { id: 'pokemon', label: 'Pokémon' }, { id: 'moves', label: 'Moves' }, { id: 'items', label: 'Items' }, + { id: 'abilities', label: 'Abilities' }, ]; const tmLabel = (raw) => { @@ -140,12 +141,30 @@ export async function SearchView() { run(); }), ); + } else if (tab === 'abilities') { + filters.append( + pill( + 'abSort', + [['name', 'A–Z'], ['count', 'Most Pokémon'], ['gen', 'Newest']], + ui.get().abSort, + (v) => { + ui.set({ abSort: v }); + run(); + }, + ), + ); } } function syncUI() { input.placeholder = - tab === 'pokemon' ? 'Name, number or type…' : tab === 'moves' ? 'Move name…' : 'Item name…'; + tab === 'pokemon' + ? 'Name, number or type…' + : tab === 'moves' + ? 'Move name…' + : tab === 'items' + ? 'Item name…' + : 'Ability name or effect…'; buildFilters(); } @@ -285,6 +304,32 @@ export async function SearchView() { } } + // ---- Abilities (from snapshot, offline) --------------------- + function renderAbilities(q) { + const sort = ui.get().abSort; + let list = snap.abilities.filter( + (a) => !q || loose(a.name).includes(q) || a.effect.toLowerCase().includes(q), + ); + list.sort((a, b) => { + if (sort === 'count') return b.pokemon.length - a.pokemon.length || a.name.localeCompare(b.name); + if (sort === 'gen') return b.generation - a.generation || a.name.localeCompare(b.name); + return a.name.localeCompare(b.name); + }); + note.textContent = `${list.length} abilit${list.length === 1 ? 'y' : 'ies'}`; + for (const a of list.slice(0, 400)) { + results.append( + el( + 'a', + { class: 'search__row search__row--ability', href: `#/ability/${a.id}` }, + el('span', { class: 'search__name' }, a.name.replace(/-/g, ' ')), + el('span', { class: 'search__cat' }, `Gen ${a.generation}`), + el('span', { class: 'search__cat' }, `${a.pokemon.length} 🐾`), + el('span', { class: 'search__ab-eff' }, a.effect || '—'), + ), + ); + } + } + function run() { const token = ++runToken; const q = loose(query); @@ -299,7 +344,8 @@ export async function SearchView() { return; } if (tab === 'moves') renderMoves(q, token); - else renderItems(q); + else if (tab === 'items') renderItems(q); + else renderAbilities(q); } run(); diff --git a/src/views/TeamView.js b/src/views/TeamView.js index 7f7b7f4..db5e731 100644 --- a/src/views/TeamView.js +++ b/src/views/TeamView.js @@ -60,7 +60,12 @@ export async function TeamView() { 'header', { class: 'view__header' }, el('h1', {}, 'Team'), - el('p', {}, `Up to ${MAX_TEAM} Pokémon — check weaknesses, coverage and stats.`), + el( + 'p', + {}, + `Up to ${MAX_TEAM} Pokémon — check weaknesses, coverage and stats. `, + el('a', { class: 'link', href: '#/natures' }, 'Natures reference'), + ), ), seg, lineup,