From f1abe071285f47559df0c99137d61f02eb26ffc2 Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 27 Aug 2026 11:35:30 -0400 Subject: [PATCH] Add moves, evolution, type matchups, locations; game-accurate data; fix desktop layout - Detail page: base stats, type matchups (defending), evolution chain, learnset with per-move power/type/acc/PP/effect, wild locations - Game-aware: abilities gated to Gen 3+ (hidden to Gen 5+), type chart applies Gen 1 / pre-Gen 6 rules, move stats use PokeAPI past_values, pre-Gen 4 physical/special-by-type, evolution tree re-parented to the selected generation - Moves and evolution clickable/expandable; no horizontal scroll - Fix: .view-host shrink-wrapped as a grid item on desktop (margin:0 auto) - Era-accurate sprite option; Search tab relabelled as global lookup Co-Authored-By: Claude Sonnet 5 --- package-lock.json | 25 ++- package.json | 3 +- src/components/Card.js | 4 +- src/components/EvolutionChain.js | 111 ++++++++++++ src/components/Locations.js | 66 +++++++ src/components/MovesList.js | 229 +++++++++++++++++++++++ src/components/Sprite.js | 32 +++- src/components/TypeMatchups.js | 35 ++++ src/data/api.js | 17 ++ src/data/game-sprites.js | 42 +++++ src/data/type-chart.js | 81 +++++++++ src/styles/layout.css | 299 +++++++++++++++++++++++++++++-- src/styles/tokens.css | 7 +- src/views/DexGrid.js | 9 +- src/views/PokemonDetail.js | 265 +++++++++++++++++++-------- src/views/SearchView.js | 7 +- src/views/SettingsView.js | 3 +- 17 files changed, 1124 insertions(+), 111 deletions(-) create mode 100644 src/components/EvolutionChain.js create mode 100644 src/components/Locations.js create mode 100644 src/components/MovesList.js create mode 100644 src/components/TypeMatchups.js create mode 100644 src/data/game-sprites.js create mode 100644 src/data/type-chart.js diff --git a/package-lock.json b/package-lock.json index ef958e8..8cd8fe8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,8 @@ }, "devDependencies": { "vite": "^5.4.21", - "vite-plugin-pwa": "^0.21.2" + "vite-plugin-pwa": "^0.21.2", + "ws": "^8.21.3" } }, "node_modules/@apideck/better-ajv-errors": { @@ -6181,6 +6182,28 @@ "workbox-core": "7.4.1" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index ff862c5..5034d0a 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "license": "MIT", "devDependencies": { "vite": "^5.4.21", - "vite-plugin-pwa": "^0.21.2" + "vite-plugin-pwa": "^0.21.2", + "ws": "^8.21.3" }, "dependencies": { "workbox-cacheable-response": "^7.4.1", diff --git a/src/components/Card.js b/src/components/Card.js index 5c70104..e59f604 100644 --- a/src/components/Card.js +++ b/src/components/Card.js @@ -8,7 +8,7 @@ import { entry, toggle } from '../store/selection.js'; * caught toggle is a real button layered on top and updates itself in place * so toggling never repaints the whole grid. */ -export function Card(species, number, { spriteStyle = 'default' } = {}) { +export function Card(species, number, { spriteStyle = 'default', versionGroup } = {}) { const state = entry(species.id); const caughtBtn = el('button', { @@ -33,7 +33,7 @@ export function Card(species, number, { spriteStyle = 'default' } = {}) { href: `#/pokemon/${species.id}`, }, el('span', { class: 'card__num' }, `#${String(number ?? species.id).padStart(4, '0')}`), - Sprite(species.id, { style: spriteStyle, alt: species.name, size: 96 }), + Sprite(species.id, { style: spriteStyle, versionGroup, alt: species.name, size: 96 }), el('span', { class: 'card__name' }, species.name.replace(/-/g, ' ')), el( 'span', diff --git a/src/components/EvolutionChain.js b/src/components/EvolutionChain.js new file mode 100644 index 0000000..3c0910a --- /dev/null +++ b/src/components/EvolutionChain.js @@ -0,0 +1,111 @@ +import { el } from '../lib/dom.js'; +import { Sprite } from './Sprite.js'; + +const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop()); + +/** Human-readable trigger for one evolution step. */ +function describe(details) { + if (!details || !details.length) return ''; + const d = details[0]; + if (d.min_level) return `Lv. ${d.min_level}`; + if (d.item) return `use ${d.item.name.replace(/-/g, ' ')}`; + if (d.trigger?.name === 'trade') { + return d.held_item ? `trade w/ ${d.held_item.name.replace(/-/g, ' ')}` : 'trade'; + } + if (d.min_happiness) return 'high friendship'; + if (d.known_move_type) return `knows a ${d.known_move_type.name} move`; + if (d.trigger?.name) return d.trigger.name.replace(/-/g, ' '); + return ''; +} + +function node(stage, style, versionGroup) { + return el( + 'a', + { class: 'evo__node', href: `#/pokemon/${stage.id}` }, + Sprite(stage.id, { style, versionGroup, alt: stage.name, size: 72 }), + el('span', { class: 'evo__name' }, stage.name.replace(/-/g, ' ')), + ); +} + +/** + * PokéAPI returns the full modern evolution tree. Re-parent it around the + * selected game's generation: stages introduced later (cross-gen evos, later + * babies) are dropped, and their in-era descendants attach to the nearest + * surviving ancestor — so Pikachu → Raichu still shows in Red/Blue even + * though Pichu doesn't, and Bellossom never appears there. + */ +export function EvolutionChain(chain, { style = 'default', versionGroup, genOf = () => 1, maxGen = 9 } = {}) { + const nodes = new Map(); + let pruned = false; + + const collect = (link, parentId) => { + const id = idFromUrl(link.species.url); + let currentId = parentId; + if (genOf(id) <= maxGen) { + if (!nodes.has(id)) { + nodes.set(id, { + id, + name: link.species.name, + parent: parentId, + via: parentId != null ? describe(link.evolution_details) : '', + kids: [], + }); + } + currentId = id; + } else { + pruned = true; + } + for (const child of link.evolves_to) collect(child, currentId); + }; + collect(chain.chain, null); + + for (const n of nodes.values()) { + if (n.parent != null && nodes.has(n.parent)) nodes.get(n.parent).kids.push(n.id); + } + + if (nodes.size <= 1) { + return el( + 'p', + { class: 'detail__muted' }, + pruned ? 'No evolutions available in this game.' : 'Does not evolve.', + ); + } + + const roots = [...nodes.values()].filter((n) => n.parent == null || !nodes.has(n.parent)); + const rows = []; + const walkPaths = (n, acc) => { + const path = [...acc, n]; + if (!n.kids.length) rows.push(path); + else for (const k of n.kids) walkPaths(nodes.get(k), path); + }; + roots.forEach((r) => walkPaths(r, [])); + + const wrap = el( + 'div', + { class: 'evo' }, + ...rows.map((path) => { + const row = el('div', { class: 'evo__row' }); + path.forEach((stage, i) => { + if (i > 0) { + row.append( + el( + 'span', + { class: 'evo__arrow' }, + '→', + stage.via ? el('span', { class: 'evo__via' }, stage.via) : null, + ), + ); + } + row.append(node(stage, style, versionGroup)); + }); + return row; + }), + ); + + if (pruned) { + wrap.append( + el('p', { class: 'evo__note' }, 'Cross-generation stages hidden for this game.'), + ); + } + return wrap; +} diff --git a/src/components/Locations.js b/src/components/Locations.js new file mode 100644 index 0000000..cadd4cc --- /dev/null +++ b/src/components/Locations.js @@ -0,0 +1,66 @@ +import { el } from '../lib/dom.js'; + +const prettify = (s) => s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); + +/** + * Wild encounter locations for the selected game, from + * /pokemon/{id}/encounters. PokéAPI has no map imagery or coordinates, so + * this is a grouped text list: area, level range, method, best odds. + */ +export function Locations(encounters, versionNames) { + const wanted = new Set(versionNames); + const areas = []; + + for (const row of encounters) { + const details = row.version_details.filter((v) => wanted.has(v.version.name)); + if (!details.length) continue; + + let min = Infinity; + let max = -Infinity; + let best = 0; + const methods = new Set(); + const conditions = new Set(); + for (const v of details) { + best = Math.max(best, v.max_chance || 0); + for (const d of v.encounter_details) { + min = Math.min(min, d.min_level); + max = Math.max(max, d.max_level); + if (d.method?.name) methods.add(prettify(d.method.name)); + for (const c of d.condition_values || []) conditions.add(prettify(c.name)); + } + } + areas.push({ + name: prettify(row.location_area.name), + levels: min === max ? `Lv ${min}` : `Lv ${min}–${max}`, + methods: [...methods].join(', '), + conditions: [...conditions].join(', '), + best, + }); + } + + if (!areas.length) { + return el( + 'p', + { class: 'detail__muted' }, + 'Not found in the wild in this game — it may be a gift, evolution, trade, fossil, or event Pokémon.', + ); + } + + areas.sort((a, b) => b.best - a.best); + + return el( + 'ul', + { class: 'locations' }, + ...areas.map((a) => + el( + 'li', + { class: 'locations__item' }, + el('span', { class: 'locations__area' }, a.name), + el('span', { class: 'locations__meta' }, a.levels), + el('span', { class: 'locations__meta' }, a.methods), + a.conditions ? el('span', { class: 'locations__cond' }, a.conditions) : null, + a.best ? el('span', { class: 'locations__rate' }, `${a.best}%`) : null, + ), + ), + ); +} diff --git a/src/components/MovesList.js b/src/components/MovesList.js new file mode 100644 index 0000000..0f45194 --- /dev/null +++ b/src/components/MovesList.js @@ -0,0 +1,229 @@ +import { el } from '../lib/dom.js'; +import { getMove, mapLimit } from '../data/api.js'; +import { TypeChip } from './TypeChip.js'; + +const METHOD_LABEL = { + 'level-up': 'By level up', + machine: 'By TM / HM', + egg: 'Egg moves', + tutor: 'Move tutor', + 'form-change': 'On form change', +}; +const METHOD_ORDER = ['level-up', 'egg', 'machine', 'tutor', 'form-change']; +const DAMAGE_CLASS = { physical: 'Phys', special: 'Spec', status: 'Stat' }; + +const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop()); + +function englishText(entries, key) { + const hit = entries.find((e) => e.language.name === 'en'); + return hit ? hit[key].replace(/\s+/g, ' ') : ''; +} + +/** + * Some moves' power/accuracy/PP/type changed between generations. PokéAPI + * ships the current values plus `past_values`; pick the set that was live in + * the selected game's generation. + */ +// Before Gen 4, a move's physical/special class was decided by its TYPE, +// not set per move. +const PHYSICAL_TYPES = new Set([ + 'normal', 'fighting', 'poison', 'ground', 'flying', 'bug', 'rock', 'ghost', 'steel', +]); + +function forGeneration(d, gen, genOfVg) { + const v = { + power: d.power, + accuracy: d.accuracy, + pp: d.pp, + type: d.type.name, + damage_class: d.damage_class ? d.damage_class.name : null, + effect_chance: d.effect_chance, + effect_entries: d.effect_entries, + }; + const past = (d.past_values || []) + .map((p) => ({ ...p, _gen: genOfVg(p.version_group && p.version_group.name) })) + .sort((a, b) => a._gen - b._gen); + for (const p of past) { + if (p._gen >= gen) { + if (p.power != null) v.power = p.power; + if (p.accuracy != null) v.accuracy = p.accuracy; + if (p.pp != null) v.pp = p.pp; + if (p.effect_chance != null) v.effect_chance = p.effect_chance; + if (p.type) v.type = p.type.name; + if (p.effect_entries && p.effect_entries.length) v.effect_entries = p.effect_entries; + break; + } + } + if (gen < 4 && v.damage_class && v.damage_class !== 'status') { + v.damage_class = PHYSICAL_TYPES.has(v.type) ? 'physical' : 'special'; + } + return v; +} + +/** + * Learnset for the selected game. Rows wrap (no sideways scroll); each row + * expands on click to show the move's effect, and power/type/accuracy/PP + * load lazily when a group is first opened. + */ +export function MovesList(pokemonMoves, { versionGroupKey, gen = 9, genOfVg = () => 9 }) { + const byMethod = new Map(); + + for (const entry of pokemonMoves) { + const detail = entry.version_group_details.find( + (d) => d.version_group.name === versionGroupKey, + ); + if (!detail) continue; + const method = detail.move_learn_method.name; + if (!byMethod.has(method)) byMethod.set(method, []); + byMethod.get(method).push({ + id: idFromUrl(entry.move.url), + name: entry.move.name.replace(/-/g, ' '), + level: detail.level_learned_at, + data: null, + }); + } + + if (byMethod.size === 0) { + return el('p', { class: 'detail__muted' }, 'No move data for this game.'); + } + + const wrap = el('div', { class: 'moves' }); + const methods = [...byMethod.keys()].sort( + (a, b) => METHOD_ORDER.indexOf(a) - METHOD_ORDER.indexOf(b), + ); + + for (const method of methods) { + const moves = byMethod.get(method); + moves.sort((a, b) => + method === 'level-up' + ? a.level - b.level || a.name.localeCompare(b.name) + : a.name.localeCompare(b.name), + ); + + const list = el('div', { class: 'moves__list' }); + const enrichers = []; + + for (const m of moves) { + const meta = el('span', { class: 'moverow__meta' }, el('span', { class: 'moverow__pending' }, '…')); + const body = el('div', { class: 'movebody', hidden: true }); + let bodyFilled = false; + + const row = el( + 'button', + { + type: 'button', + class: 'moverow', + 'aria-expanded': 'false', + onclick: async () => { + const open = body.hidden; + body.hidden = !open; + row.setAttribute('aria-expanded', String(open)); + if (open && !bodyFilled) { + bodyFilled = true; + body.replaceChildren(el('span', { class: 'moverow__pending' }, 'Loading…')); + try { + m.data = m.data || (await getMove(m.id)); + const v = forGeneration(m.data, gen, genOfVg); + fillMeta(meta, v); + fillBody(body, m.data, v); + } catch { + body.replaceChildren(el('span', { class: 'moverow__pending' }, 'Details unavailable.')); + } + } + }, + }, + method === 'level-up' + ? el('span', { class: 'moverow__lv' }, m.level ? `Lv ${m.level}` : '—') + : null, + el('span', { class: 'moverow__name' }, m.name), + meta, + el('span', { class: 'moverow__chev', 'aria-hidden': 'true' }, '▾'), + ); + + list.append(el('div', { class: 'movecard' }, row, body)); + + enrichers.push(async () => { + try { + m.data = m.data || (await getMove(m.id)); + fillMeta(meta, forGeneration(m.data, gen, genOfVg)); + } catch { + meta.replaceChildren(el('span', { class: 'moverow__pending' }, '—')); + } + }); + } + + const group = el( + 'details', + { class: 'moves__group', open: method === 'level-up' }, + el( + 'summary', + {}, + `${METHOD_LABEL[method] || method} `, + el('span', { class: 'moves__count' }, `(${moves.length})`), + ), + list, + ); + + let enriched = false; + const runEnrich = async () => { + if (enriched) return; + enriched = true; + await mapLimit(enrichers, 6, (fn) => fn()); + }; + group.addEventListener('toggle', () => group.open && runEnrich()); + if (method === 'level-up') runEnrich(); + + wrap.append(group); + } + + return wrap; +} + +function fillMeta(meta, v) { + meta.replaceChildren( + TypeChip(v.type), + el( + 'span', + { class: 'moverow__cat', dataset: { cat: v.damage_class || '' } }, + DAMAGE_CLASS[v.damage_class] || '—', + ), + stat('Pow', v.power ?? '—'), + stat('Acc', v.accuracy != null ? `${v.accuracy}%` : '—'), + stat('PP', v.pp ?? '—'), + ); +} + +function fillBody(body, d, v) { + const chance = v.effect_chance; + let effect = + englishText(v.effect_entries, 'short_effect') || + englishText(d.flavor_text_entries, 'flavor_text') || + 'No description.'; + if (chance != null) effect = effect.replace(/\$effect_chance%?/g, `${chance}%`); + + const bits = []; + if (d.priority) bits.push(`Priority ${d.priority > 0 ? '+' : ''}${d.priority}`); + if (d.target?.name) bits.push(`Target: ${d.target.name.replace(/-/g, ' ')}`); + if (d.meta?.ailment?.name && d.meta.ailment.name !== 'none') { + bits.push(`May ${d.meta.ailment.name.replace(/-/g, ' ')}${d.meta.ailment_chance ? ` (${d.meta.ailment_chance}%)` : ''}`); + } + if (d.meta?.drain) bits.push(`${d.meta.drain > 0 ? 'Drains' : 'Recoil'} ${Math.abs(d.meta.drain)}%`); + if (d.meta?.healing) bits.push(`Heals ${d.meta.healing}%`); + if (d.meta?.crit_rate) bits.push(`+${d.meta.crit_rate} crit rate`); + if (d.meta?.flinch_chance) bits.push(`Flinch ${d.meta.flinch_chance}%`); + + body.replaceChildren( + el('p', { class: 'movebody__effect' }, effect), + bits.length ? el('p', { class: 'movebody__bits' }, bits.join(' · ')) : null, + ); +} + +function stat(label, value) { + return el( + 'span', + { class: 'moverow__stat' }, + el('span', { class: 'moverow__stat-k' }, label), + ' ', + String(value), + ); +} diff --git a/src/components/Sprite.js b/src/components/Sprite.js index 5c13a15..fef993c 100644 --- a/src/components/Sprite.js +++ b/src/components/Sprite.js @@ -1,20 +1,38 @@ import { el } from '../lib/dom.js'; +import { gameSpriteUrl } from '../data/game-sprites.js'; const SPRITES = 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon'; -export function spriteUrl(id, style = 'default', shiny = false) { - const s = shiny ? '/shiny' : ''; +function pixel(id, shiny) { + return `${SPRITES}${shiny ? '/shiny' : ''}/${id}.png`; +} + +/** + * Resolve a sprite URL for a style. `game` uses era-accurate pixel art for + * the selected version group, falling back to HOME art for games that never + * had pixel sprites. + */ +export function spriteUrl(id, style = 'default', shiny = false, versionGroup) { if (style === 'official') { return `${SPRITES}/other/official-artwork${shiny ? '/shiny' : ''}/${id}.png`; } if (style === 'home') { return `${SPRITES}/other/home${shiny ? '/shiny' : ''}/${id}.png`; } - return `${SPRITES}${s}/${id}.png`; + if (style === 'game') { + return ( + gameSpriteUrl(id, versionGroup, { shiny }) || + `${SPRITES}/other/home${shiny ? '/shiny' : ''}/${id}.png` + ); + } + return pixel(id, shiny); } -export function Sprite(id, { style = 'default', shiny = false, alt = '', size = 96 } = {}) { +export function Sprite( + id, + { style = 'default', shiny = false, versionGroup, alt = '', size = 96 } = {}, +) { const img = el('img', { class: 'sprite', loading: 'lazy', @@ -22,13 +40,13 @@ export function Sprite(id, { style = 'default', shiny = false, alt = '', size = width: size, height: size, alt, - src: spriteUrl(id, style, shiny), + src: spriteUrl(id, style, shiny, versionGroup), }); - // Not every form has official/home art — fall back to the pixel sprite. + // Not every form/style has art — degrade to the plain pixel sprite once. img.addEventListener( 'error', () => { - img.src = spriteUrl(id, 'default', false); + img.src = pixel(id, false); }, { once: true }, ); diff --git a/src/components/TypeMatchups.js b/src/components/TypeMatchups.js new file mode 100644 index 0000000..f18096a --- /dev/null +++ b/src/components/TypeMatchups.js @@ -0,0 +1,35 @@ +import { el } from '../lib/dom.js'; +import { TypeChip } from './TypeChip.js'; +import { defensiveMatchups } from '../data/type-chart.js'; + +const GROUPS = [ + { key: '4', label: '4× damage', cls: 'x4' }, + { key: '2', label: '2× damage', cls: 'x2' }, + { key: '0.5', label: '½× damage', cls: 'half' }, + { key: '0.25', label: '¼× damage', cls: 'quarter' }, + { key: '0', label: 'No effect', cls: 'zero' }, +]; + +/** Defensive type effectiveness for the given Pokémon types. */ +export function TypeMatchups(types, gen = 9) { + const buckets = defensiveMatchups(types, gen); + const wrap = el('div', { class: 'matchups' }); + + for (const g of GROUPS) { + const list = buckets[g.key]; + if (!list.length) continue; + wrap.append( + el( + 'div', + { class: `matchups__row matchups__row--${g.cls}` }, + el('span', { class: 'matchups__label' }, g.label), + el('span', { class: 'matchups__types' }, ...list.map(TypeChip)), + ), + ); + } + + if (!wrap.children.length) { + return el('p', { class: 'detail__muted' }, 'Takes normal damage from every type.'); + } + return wrap; +} diff --git a/src/data/api.js b/src/data/api.js index f8a88a0..12e4653 100644 --- a/src/data/api.js +++ b/src/data/api.js @@ -24,3 +24,20 @@ export function getJSON(path) { export const getPokemon = (idOrName) => getJSON(`pokemon/${idOrName}`); export const getSpecies = (idOrName) => getJSON(`pokemon-species/${idOrName}`); export const getEvolutionChain = (id) => getJSON(`evolution-chain/${id}`); +export const getMove = (idOrName) => getJSON(`move/${idOrName}`); +export const getEncounters = (id) => getJSON(`pokemon/${id}/encounters`); + +/** Run async `fn` over `items` with a bounded number of parallel workers. */ +export async function mapLimit(items, limit, fn) { + const out = new Array(items.length); + let i = 0; + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, async () => { + while (i < items.length) { + const idx = i++; + out[idx] = await fn(items[idx], idx); + } + }), + ); + return out; +} diff --git a/src/data/game-sprites.js b/src/data/game-sprites.js new file mode 100644 index 0000000..6069ff3 --- /dev/null +++ b/src/data/game-sprites.js @@ -0,0 +1,42 @@ +/** + * Era-accurate sprites: the PokeAPI sprites repo keeps per-generation pixel + * art under `versions///`. This maps a selected game (version + * group) to the closest sprite folder. Games newer than Gen 7 shipped no + * pixel sprites — those return null and the caller falls back to HOME art. + */ +const REPO = + 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions'; + +const PATH_BY_VERSION_GROUP = { + 'red-blue': 'generation-i/red-blue', + 'red-green-japan': 'generation-i/red-blue', + 'blue-japan': 'generation-i/red-blue', + yellow: 'generation-i/yellow', + 'gold-silver': 'generation-ii/gold', + crystal: 'generation-ii/crystal', + 'ruby-sapphire': 'generation-iii/ruby-sapphire', + emerald: 'generation-iii/emerald', + 'firered-leafgreen': 'generation-iii/firered-leafgreen', + 'diamond-pearl': 'generation-iv/diamond-pearl', + platinum: 'generation-iv/platinum', + 'heartgold-soulsilver': 'generation-iv/heartgold-soulsilver', + 'black-white': 'generation-v/black-white', + 'black-2-white-2': 'generation-v/black-white', + 'x-y': 'generation-vi/x-y', + 'omega-ruby-alpha-sapphire': 'generation-vi/omegaruby-alphasapphire', + 'sun-moon': 'generation-vii/ultra-sun-ultra-moon', + 'ultra-sun-ultra-moon': 'generation-vii/ultra-sun-ultra-moon', + 'brilliant-diamond-and-shining-pearl': + 'generation-viii/brilliant-diamond-shining-pearl', +}; + +/** URL for a game-era pixel sprite, or null if that game has none. */ +export function gameSpriteUrl(id, versionGroupKey, { shiny = false } = {}) { + const path = PATH_BY_VERSION_GROUP[versionGroupKey]; + if (!path) return null; + return `${REPO}/${path}/${shiny ? 'shiny/' : ''}${id}.png`; +} + +export function hasGameSprites(versionGroupKey) { + return Boolean(PATH_BY_VERSION_GROUP[versionGroupKey]); +} diff --git a/src/data/type-chart.js b/src/data/type-chart.js new file mode 100644 index 0000000..37b4317 --- /dev/null +++ b/src/data/type-chart.js @@ -0,0 +1,81 @@ +/** + * Static type effectiveness chart (Gen 6+, 18 types). Baked in so matchups + * work offline with zero fetches. + * + * CHART[attacking][defending] holds only the non-1x multipliers. + */ +export const TYPES = [ + 'normal', 'fire', 'water', 'electric', 'grass', 'ice', 'fighting', 'poison', + 'ground', 'flying', 'psychic', 'bug', 'rock', 'ghost', 'dragon', 'dark', + 'steel', 'fairy', +]; + +const CHART = { + normal: { rock: 0.5, ghost: 0, steel: 0.5 }, + fire: { fire: 0.5, water: 0.5, grass: 2, ice: 2, bug: 2, rock: 0.5, dragon: 0.5, steel: 2 }, + water: { fire: 2, water: 0.5, grass: 0.5, ground: 2, rock: 2, dragon: 0.5 }, + electric: { water: 2, electric: 0.5, grass: 0.5, ground: 0, flying: 2, dragon: 0.5 }, + grass: { fire: 0.5, water: 2, grass: 0.5, poison: 0.5, ground: 2, flying: 0.5, bug: 0.5, rock: 2, dragon: 0.5, steel: 0.5 }, + ice: { fire: 0.5, water: 0.5, grass: 2, ice: 0.5, ground: 2, flying: 2, dragon: 2, steel: 0.5 }, + fighting: { normal: 2, ice: 2, poison: 0.5, flying: 0.5, psychic: 0.5, bug: 0.5, rock: 2, ghost: 0, dark: 2, steel: 2, fairy: 0.5 }, + poison: { grass: 2, poison: 0.5, ground: 0.5, rock: 0.5, ghost: 0.5, steel: 0, fairy: 2 }, + ground: { fire: 2, electric: 2, grass: 0.5, poison: 2, flying: 0, bug: 0.5, rock: 2, steel: 2 }, + flying: { electric: 0.5, grass: 2, fighting: 2, bug: 2, rock: 0.5, steel: 0.5 }, + psychic: { fighting: 2, poison: 2, psychic: 0.5, dark: 0, steel: 0.5 }, + bug: { fire: 0.5, grass: 2, fighting: 0.5, poison: 0.5, flying: 0.5, psychic: 2, ghost: 0.5, dark: 2, steel: 0.5, fairy: 0.5 }, + rock: { fire: 2, ice: 2, fighting: 0.5, ground: 0.5, flying: 2, bug: 2, steel: 0.5 }, + ghost: { normal: 0, psychic: 2, ghost: 2, dark: 0.5 }, + dragon: { dragon: 2, steel: 0.5, fairy: 0 }, + dark: { fighting: 0.5, psychic: 2, ghost: 2, dark: 0.5, fairy: 0.5 }, + steel: { fire: 0.5, water: 0.5, electric: 0.5, ice: 2, rock: 2, steel: 0.5, fairy: 2 }, + fairy: { fire: 0.5, fighting: 2, poison: 0.5, dragon: 2, dark: 2, steel: 0.5 }, +}; + +// Per-era overrides, applied on top of the modern (Gen 6+) chart. +// attacking -> { defending: multiplier } +const PRE_GEN6 = { + ghost: { steel: 0.5 }, // Steel resisted Ghost/Dark before Gen 6 + dark: { steel: 0.5 }, +}; +const GEN1_ONLY = { + bug: { poison: 2 }, // Bug and Poison were mutually super-effective + poison: { bug: 2 }, + ghost: { psychic: 0 }, // famous Gen 1 bug: Ghost did nothing to Psychic +}; + +function pairMultiplier(attacking, def, gen) { + if (gen <= 1 && GEN1_ONLY[attacking] && def in GEN1_ONLY[attacking]) { + return GEN1_ONLY[attacking][def]; + } + if (gen < 6 && PRE_GEN6[attacking] && def in PRE_GEN6[attacking]) { + return PRE_GEN6[attacking][def]; + } + const row = CHART[attacking] || {}; + return def in row ? row[def] : 1; +} + +/** Multiplier of `attacking` type against a defender of `defTypes`. */ +export function multiplier(attacking, defTypes, gen = 9) { + let m = 1; + for (const def of defTypes) { + if (gen < 6 && def === 'fairy') continue; // Fairy did not exist + m *= pairMultiplier(attacking, def, gen); + } + return m; +} + +/** + * Defensive matchups for a Pokémon, bucketed by multiplier. + * Returns { '4': [...types], '2': [...], '0.5': [...], '0.25': [...], '0': [...] } + */ +export function defensiveMatchups(defTypes, gen = 9) { + const buckets = { '4': [], '2': [], '0.5': [], '0.25': [], '0': [] }; + const atkTypes = gen < 6 ? TYPES.filter((t) => t !== 'fairy') : TYPES; + for (const atk of atkTypes) { + const m = multiplier(atk, defTypes, gen); + if (m === 1) continue; + const key = String(m); + if (buckets[key]) buckets[key].push(atk); + } + return buckets; +} diff --git a/src/styles/layout.css b/src/styles/layout.css index 5998f1e..b4fe524 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -4,8 +4,11 @@ } .view-host { + /* Explicit width (not just max-width) so that as a grid item on desktop it + fills the column instead of shrink-wrapping to its content. */ + width: 100%; max-width: var(--maxw); - margin: 0 auto; + margin-inline: auto; padding: var(--gap); padding-bottom: calc(var(--nav-size) + env(safe-area-inset-bottom) + var(--gap)); } @@ -256,6 +259,11 @@ grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); } } +@media (min-width: 1200px) { + .grid { + grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); + } +} .grid__empty { grid-column: 1 / -1; text-align: center; @@ -401,17 +409,25 @@ /* ---------- Detail ---------------------------------------------- */ .detail { - max-width: 640px; + max-width: 1100px; margin: 0 auto; } .detail__nav { display: flex; justify-content: space-between; - gap: 8px; + align-items: baseline; + gap: 8px 12px; + flex-wrap: wrap; margin-bottom: 8px; font-size: 0.9rem; text-transform: capitalize; } +.detail__nav a { + max-width: 45vw; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .detail__num { margin: 0; color: var(--text-dim); @@ -427,16 +443,45 @@ display: flex; gap: 6px; } + +.detail__panels { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + align-items: start; + margin-top: 16px; +} +.detail__section { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + margin: 0; + min-width: 0; +} +.detail__section h2 { + font-size: 0.95rem; + margin: 0 0 12px; +} +.detail__section--wide { + grid-column: 1 / -1; +} +.detail__section--media { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + .detail__media { display: flex; flex-direction: column; align-items: center; gap: 10px; - padding: 16px 0; } .detail__media .sprite { - width: min(240px, 70vw); - height: min(240px, 70vw); + width: min(240px, 60vw); + height: min(240px, 60vw); object-fit: contain; } .detail__track { @@ -444,7 +489,10 @@ gap: 8px; justify-content: center; flex-wrap: wrap; - margin-bottom: 16px; +} +.detail__muted { + color: var(--text-dim); + margin: 0; } .toggle { appearance: none; @@ -469,12 +517,8 @@ padding: 14px 16px; font-style: italic; color: var(--text-dim); -} -.detail__stats { - margin-top: 20px; -} -.detail__stats h2 { - font-size: 1rem; + max-width: 60ch; + overflow-wrap: anywhere; } .statbar { display: grid; @@ -510,9 +554,9 @@ } .detail__facts { display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px 16px; - margin-top: 20px; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px 16px; + margin-top: 22px; } .fact dt { font-size: 0.72rem; @@ -522,8 +566,231 @@ } .fact dd { margin: 2px 0 0; +} + +/* ---------- Evolution chain ----------------------------------- */ +.evo { + display: flex; + flex-direction: column; + gap: 14px; +} +.evo__row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} +.evo__node { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + text-decoration: none; + color: inherit; + padding: 6px 8px; + border-radius: var(--radius-sm); +} +.evo__node:hover { + background: var(--surface-2); +} +.evo__node .sprite { + width: 72px; + height: 72px; + object-fit: contain; + image-rendering: pixelated; +} +.evo__name { + font-size: 0.8rem; + font-weight: 600; text-transform: capitalize; } +.evo__arrow { + display: flex; + flex-direction: column; + align-items: center; + color: var(--text-dim); + font-size: 0.95rem; +} +.evo__via { + font-size: 0.66rem; + text-transform: capitalize; + white-space: nowrap; +} + +/* ---------- Type matchups ---------------------------------- */ +.matchups { + display: flex; + flex-direction: column; + gap: 8px; +} +.matchups__row { + display: grid; + grid-template-columns: 84px 1fr; + align-items: center; + gap: 12px; +} +.matchups__label { + font-size: 0.78rem; + font-weight: 700; + color: var(--text-dim); +} +.matchups__row--x4 .matchups__label, +.matchups__row--x2 .matchups__label { + color: var(--danger); +} +.matchups__row--half .matchups__label, +.matchups__row--quarter .matchups__label { + color: var(--good); +} +.matchups__types { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +/* ---------- Moves ------------------------------------------- */ +.moves { + display: flex; + flex-direction: column; + gap: 8px; +} +.moves__group { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 14px; + background: var(--bg); +} +.moves__group summary { + cursor: pointer; + font-weight: 600; + font-size: 0.9rem; +} +.moves__count { + color: var(--text-dim); + font-weight: 400; +} +.moves__list { + margin-top: 8px; +} +.movecard { + border-top: 1px solid var(--border); +} +.moverow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + width: 100%; + padding: 8px 2px; + background: none; + border: none; + color: inherit; + font: inherit; + font-size: 0.82rem; + text-align: left; + cursor: pointer; +} +.moverow:hover { + color: var(--text); +} +.moverow__chev { + color: var(--text-dim); + transition: transform 0.15s ease; +} +.moverow[aria-expanded="true"] .moverow__chev { + transform: rotate(180deg); +} +.movebody { + padding: 0 2px 12px; + font-size: 0.82rem; +} +.movebody__effect { + margin: 0 0 6px; +} +.movebody__bits { + margin: 0; + color: var(--text-dim); + font-size: 0.76rem; +} +.moverow__lv { + flex: 0 0 3.2rem; + color: var(--text-dim); + font-variant-numeric: tabular-nums; +} +.moverow__name { + flex: 1 1 7rem; + min-width: 0; + font-weight: 600; + text-transform: capitalize; +} +.moverow__meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + color: var(--text-dim); +} +.moverow__pending { + font-style: italic; +} +.moverow__cat { + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; +} +.moverow__cat[data-cat="physical"] { color: #e0762f; } +.moverow__cat[data-cat="special"] { color: #4b8fd6; } +.moverow__cat[data-cat="status"] { color: #8a8f98; } +.moverow__stat { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.moverow__stat-k { + font-size: 0.66rem; + text-transform: uppercase; + letter-spacing: 0.03em; + opacity: 0.7; +} + +/* ---------- Locations ------------------------------------- */ +.locations { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 6px; +} +.locations__item { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 12px; + padding: 7px 0; + border-top: 1px solid var(--border); + font-size: 0.85rem; +} +.locations__item:first-child { + border-top: none; +} +.locations__area { + font-weight: 600; + flex: 1 1 10rem; +} +.locations__meta { + color: var(--text-dim); + font-size: 0.8rem; +} +.locations__cond { + color: var(--text-dim); + font-size: 0.72rem; + font-style: italic; +} +.locations__rate { + font-variant-numeric: tabular-nums; + color: var(--good); + font-size: 0.8rem; +} /* ---------- Search --------------------------------------------- */ .search__results { diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 6f050d2..66bb14a 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -16,7 +16,7 @@ --radius-sm: 9px; --gap: 16px; --nav-size: 64px; - --maxw: 1200px; + --maxw: 1440px; --shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 6px 20px rgba(0, 0, 0, 0.06); --font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; @@ -76,6 +76,11 @@ html, body { margin: 0; padding: 0; + overflow-x: clip; /* contain accidental overflow without breaking sticky */ +} + +img { + max-width: 100%; } body { diff --git a/src/views/DexGrid.js b/src/views/DexGrid.js index affd39f..e6faafb 100644 --- a/src/views/DexGrid.js +++ b/src/views/DexGrid.js @@ -24,7 +24,7 @@ export async function DexGrid() { const search = el('input', { class: 'dexgrid__search', type: 'search', - placeholder: 'Filter by name or number…', + placeholder: 'Filter this Pokédex…', oninput: (e) => { query = e.target.value.trim().toLowerCase(); paintGrid(); @@ -85,7 +85,12 @@ export async function DexGrid() { favorite: false, }; if (!filterTest(e)) continue; - frag.append(Card(species, number, { spriteStyle: st.spriteStyle })); + frag.append( + Card(species, number, { + spriteStyle: st.spriteStyle, + versionGroup: st.versionGroup, + }), + ); shown++; } diff --git a/src/views/PokemonDetail.js b/src/views/PokemonDetail.js index c05c36a..82a2c7a 100644 --- a/src/views/PokemonDetail.js +++ b/src/views/PokemonDetail.js @@ -1,17 +1,34 @@ import { el, clear, onTeardown } from '../lib/dom.js'; import { loadSnapshot } from '../data/snapshot.js'; import { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js'; -import { getPokemon, getSpecies } from '../data/api.js'; +import { getPokemon, getSpecies, getEvolutionChain, getEncounters } from '../data/api.js'; import { settings } from '../store/settings.js'; import { entry, toggle } from '../store/selection.js'; import { Sprite } from '../components/Sprite.js'; import { TypeChip } from '../components/TypeChip.js'; import { StatBar } from '../components/StatBar.js'; +import { EvolutionChain } from '../components/EvolutionChain.js'; +import { MovesList } from '../components/MovesList.js'; +import { TypeMatchups } from '../components/TypeMatchups.js'; +import { Locations } from '../components/Locations.js'; + +const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop()); + +const STAT_NAMES = [ + 'hp', + 'attack', + 'defense', + 'special-attack', + 'special-defense', + 'speed', +]; export async function PokemonDetail(nationalId) { const view = el('section', { class: 'view detail' }); const snap = await loadSnapshot(); const st = settings.get(); + const vg = snap.versionGroupByKey.get(st.versionGroup); + const vgGen = vg ? vg.generation : 9; // Prev / next within the currently selected dex. const dex = resolvePokedex(snap, st); @@ -21,9 +38,7 @@ export async function PokemonDetail(nationalId) { const next = pos >= 0 && pos < rows.length - 1 ? rows[pos + 1] : null; const regionalNumber = pos >= 0 ? rows[pos].number : null; - view.append( - el('div', { class: 'detail__skeleton' }, 'Loading Pokémon…'), - ); + view.append(el('div', { class: 'view--loading' }, 'Loading Pokémon…')); let pokemon; let species; @@ -34,7 +49,9 @@ export async function PokemonDetail(nationalId) { ]); } catch (err) { clear(view).append( - el('div', { class: 'view--error' }, + el( + 'div', + { class: 'view--error' }, el('h1', {}, 'Could not load this Pokémon'), el('p', {}, navigator.onLine ? err.message : 'You appear to be offline.'), el('a', { class: 'button', href: '#/' }, 'Back to the dex'), @@ -43,38 +60,37 @@ export async function PokemonDetail(nationalId) { return view; } + // ---- Artwork + shiny toggle ----------------------------------------- + const artStyle = st.spriteStyle === 'default' ? 'official' : st.spriteStyle; let shiny = st.showShiny; + const media = el('div', { class: 'detail__media' }); - const artwork = Sprite(nationalId, { - style: st.spriteStyle === 'default' ? 'official' : st.spriteStyle, - shiny, - alt: species.name, - size: 240, - }); + function paintArt() { + media.replaceChildren( + Sprite(nationalId, { + style: artStyle, + shiny, + versionGroup: st.versionGroup, + alt: species.name, + size: 240, + }), + el( + 'button', + { + class: 'button button--ghost', + type: 'button', + onclick: () => { + shiny = !shiny; + paintArt(); + }, + }, + shiny ? 'Show normal' : 'Show shiny', + ), + ); + } + paintArt(); - const shinyToggle = el( - 'button', - { - class: 'button button--ghost', - type: 'button', - onclick: () => { - shiny = !shiny; - const fresh = Sprite(nationalId, { - style: st.spriteStyle === 'default' ? 'official' : st.spriteStyle, - shiny, - alt: species.name, - size: 240, - }); - artwork.replaceWith(fresh); - artworkRef.node = fresh; - shinyToggle.textContent = shiny ? 'Show normal' : 'Show shiny'; - }, - }, - shiny ? 'Show normal' : 'Show shiny', - ); - const artworkRef = { node: artwork }; - - // Caught / seen / favorite controls — write to the unified selection. + // ---- Seen / caught / favorite (writes the unified selection) -------- const trackBar = el('div', { class: 'detail__track' }); function renderTrack() { const e = entry(nationalId); @@ -101,28 +117,81 @@ export async function PokemonDetail(nationalId) { } renderTrack(); - // Flavor text for the currently selected game, newest fallback otherwise. - const vg = snap.versionGroupByKey.get(st.versionGroup); + // ---- Flavor text for the selected game ----------------------------- const wantedVersions = new Set(vg ? vg.versions : []); - const englishEntries = species.flavor_text_entries.filter( + const englishFlavor = species.flavor_text_entries.filter( (f) => f.language.name === 'en', ); const flavor = - englishEntries.find((f) => wantedVersions.has(f.version.name)) || - englishEntries[englishEntries.length - 1]; + englishFlavor.find((f) => wantedVersions.has(f.version.name)) || + englishFlavor[englishFlavor.length - 1]; + // ---- Stats -------------------------------------------------------- const stat = (name) => pokemon.stats.find((s) => s.stat.name === name)?.base_stat ?? 0; - const statNames = [ - 'hp', - 'attack', - 'defense', - 'special-attack', - 'special-defense', - 'speed', - ]; - const total = statNames.reduce((sum, n) => sum + stat(n), 0); + const statTotal = STAT_NAMES.reduce((sum, n) => sum + stat(n), 0); + // ---- Abilities: Gen 3+ only; hidden abilities Gen 5+ ------------- + const abilities = pokemon.abilities + .filter((a) => !a.is_hidden || vgGen >= 5) + .map((a) => prettify(a.ability.name) + (a.is_hidden ? ' (hidden)' : '')); + + // ---- Evolution section (filled in async) ------------------------- + const evoSection = el( + 'section', + { class: 'detail__section' }, + el('h2', {}, 'Evolution'), + el('p', { class: 'detail__muted' }, 'Loading…'), + ); + loadEvolution(species, st, snap, vgGen).then((node) => { + evoSection.replaceChildren(el('h2', {}, 'Evolution'), node); + }); + + const movesSection = el( + 'section', + { class: 'detail__section detail__section--wide' }, + el('h2', {}, `Moves — ${prettify(st.versionGroup)}`), + MovesList(pokemon.moves, { + versionGroupKey: st.versionGroup, + gen: vgGen, + genOfVg: (name) => snap.versionGroupByKey.get(name)?.generation ?? 9, + }), + ); + + // PokéAPI has no wild-encounter data for these games yet. + const NO_ENCOUNTER_DATA = new Set([ + 'scarlet-violet', + 'the-teal-mask', + 'the-indigo-disk', + 'brilliant-diamond-and-shining-pearl', + 'legends-arceus', + ]); + const locHeading = () => el('h2', {}, `Where to find — ${prettify(st.versionGroup)}`); + const locSection = el( + 'section', + { class: 'detail__section detail__section--wide' }, + locHeading(), + el('p', { class: 'detail__muted' }, 'Loading…'), + ); + if (NO_ENCOUNTER_DATA.has(st.versionGroup)) { + locSection.replaceChildren( + locHeading(), + el('p', { class: 'detail__muted' }, "PokéAPI doesn't have wild-encounter data for this game yet."), + ); + } else { + getEncounters(nationalId) + .then((data) => { + locSection.replaceChildren(locHeading(), Locations(data, vg ? vg.versions : [])); + }) + .catch(() => { + locSection.replaceChildren( + locHeading(), + el('p', { class: 'detail__muted' }, 'Location data unavailable offline.'), + ); + }); + } + + // ---- Assemble --------------------------------------------------- clear(view).append( el( 'nav', @@ -138,41 +207,65 @@ export async function PokemonDetail(nationalId) { el( 'header', { class: 'detail__header' }, - el('p', { class: 'detail__num' }, + el( + 'p', + { class: 'detail__num' }, regionalNumber != null ? `#${String(regionalNumber).padStart(3, '0')} · ${prettify(dex.name || dex.key)}` - : `#${String(nationalId).padStart(4, '0')} · National`), + : `#${String(nationalId).padStart(4, '0')} · National`, + ), el('h1', { class: 'detail__name' }, species.name.replace(/-/g, ' ')), - el('div', { class: 'detail__types' }, ...pokemon.types.map((t) => TypeChip(t.type.name))), + el( + 'div', + { class: 'detail__types' }, + ...pokemon.types.map((t) => TypeChip(t.type.name)), + ), ), - el( - 'div', - { class: 'detail__media' }, - artwork, - shinyToggle, - ), - trackBar, flavor ? el('p', { class: 'detail__flavor' }, flavor.flavor_text.replace(/\s+/g, ' ')) : null, el( - 'section', - { class: 'detail__stats' }, - el('h2', {}, 'Base stats'), - ...statNames.map((n) => StatBar(n, stat(n))), - el('div', { class: 'statbar statbar--total' }, - el('span', { class: 'statbar__label' }, 'Total'), - el('span', { class: 'statbar__value' }, String(total)), - el('div', { class: 'statbar__track' }), + 'div', + { class: 'detail__panels' }, + el('section', { class: 'detail__section detail__section--media' }, media, trackBar), + el( + 'section', + { class: 'detail__section' }, + el('h2', {}, 'Base stats'), + ...STAT_NAMES.map((n) => StatBar(n, stat(n))), + el( + 'div', + { class: 'statbar statbar--total' }, + el('span', { class: 'statbar__label' }, 'Total'), + el('span', { class: 'statbar__value' }, String(statTotal)), + el('div', { class: 'statbar__track' }), + ), ), - ), - el( - 'dl', - { class: 'detail__facts' }, - fact('Height', `${pokemon.height / 10} m`), - fact('Weight', `${pokemon.weight / 10} kg`), - fact('Abilities', pokemon.abilities.map((a) => prettify(a.ability.name) + (a.is_hidden ? ' (hidden)' : '')).join(', ')), - fact('Generation', prettify(species.generation.name)), + el( + 'section', + { class: 'detail__section' }, + el('h2', {}, 'Details'), + el( + 'dl', + { class: 'detail__facts' }, + 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, + fact('Introduced', prettify(species.generation.name)), + ), + ), + el( + 'section', + { class: 'detail__section' }, + el('h2', {}, 'Type matchups (defending)'), + TypeMatchups( + pokemon.types.map((t) => t.type.name), + vgGen, + ), + ), + evoSection, + locSection, + movesSection, ), ); @@ -181,9 +274,23 @@ export async function PokemonDetail(nationalId) { return view; } -function fact(label, value) { - return el('div', { class: 'fact' }, - el('dt', {}, label), - el('dd', {}, value), - ); +async function loadEvolution(species, st, snap, vgGen) { + if (!species.evolution_chain?.url) { + return el('p', { class: 'detail__muted' }, 'Does not evolve.'); + } + try { + const chain = await getEvolutionChain(idFromUrl(species.evolution_chain.url)); + return EvolutionChain(chain, { + style: st.spriteStyle === 'default' ? 'default' : st.spriteStyle, + versionGroup: st.versionGroup, + maxGen: vgGen, + genOf: (id) => snap.speciesById.get(id)?.generation ?? 1, + }); + } catch { + return el('p', { class: 'detail__muted' }, 'Evolution data unavailable offline.'); + } +} + +function fact(label, value) { + return el('div', { class: 'fact' }, el('dt', {}, label), el('dd', {}, value)); } diff --git a/src/views/SearchView.js b/src/views/SearchView.js index 1d75b0c..ae05c98 100644 --- a/src/views/SearchView.js +++ b/src/views/SearchView.js @@ -23,7 +23,12 @@ export async function SearchView() { }); view.append( - el('header', { class: 'view__header' }, el('h1', {}, 'Search')), + el( + 'header', + { class: 'view__header' }, + el('h1', {}, 'Search all Pokémon'), + el('p', {}, 'Look up any Pokémon from any game — not limited to your selected Pokédex.'), + ), input, results, ); diff --git a/src/views/SettingsView.js b/src/views/SettingsView.js index c5ba9bd..e96b3a2 100644 --- a/src/views/SettingsView.js +++ b/src/views/SettingsView.js @@ -16,7 +16,8 @@ export async function SettingsView() { }); const spriteField = selectField('Sprite style', st.spriteStyle, [ - ['default', 'Pixel'], + ['default', 'Pixel (modern)'], + ['game', 'Game era (pixel art from the selected game)'], ['official', 'Official artwork'], ['home', 'Pokémon HOME'], ], (v) => settings.set({ spriteStyle: v }));