import { el, clear, onTeardown } from '../lib/dom.js'; import { onSwipe } from '../lib/swipe.js'; import { loadSnapshot } from '../data/snapshot.js'; import { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js'; import { getPokemon, getSpecies, getEvolutionChain, getEncounters } from '../data/api.js'; import { settings } from '../store/settings.js'; import { ui } from '../store/ui.js'; import { entry, toggle } from '../store/selection.js'; import { team, addToTeam, removeFromTeam, inTeam, MAX_TEAM } from '../store/team.js'; import { isFormCaught, toggleFormCaught } from '../store/formTracking.js'; import { Sprite, spriteUrl } 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'; import { FlavorText } from '../components/FlavorText.js'; import { typeHex } from '../lib/type-color.js'; import { offensiveSummary } from '../data/type-chart.js'; const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop()); const STAT_LABEL = { hp: 'HP', attack: 'Atk', defense: 'Def', 'special-attack': 'SpA', 'special-defense': 'SpD', speed: 'Spe', }; const GROWTH_EXP = { erratic: 600000, fast: 800000, 'medium-fast': 1000000, 'medium-slow': 1059860, slow: 1250000, fluctuating: 1640000, }; /** "87.5% ♀ 12.5% ♂" or "Genderless", plus a two-tone bar. */ function genderCell(rate) { if (rate == null || rate < 0) return el('dd', {}, 'Genderless'); const female = (rate / 8) * 100; const bar = el('div', { class: 'gender-bar' }); bar.style.setProperty('--f', `${female}%`); return el( 'dd', { class: 'gender' }, bar, el( 'span', {}, female < 100 ? `${(100 - female).toFixed(female % 12.5 ? 1 : 0)}% ♂` : '', female > 0 && female < 100 ? ' · ' : '', female > 0 ? `${female.toFixed(female % 12.5 ? 1 : 0)}% ♀` : '', ), ); } /** A Pokémon's typing as it was in the selected game's generation. */ function typesForGeneration(pokemon, gen) { const past = (pokemon.past_types || []) .map((p) => ({ types: p.types, _gen: idFromUrl(p.generation.url) })) .sort((a, b) => a._gen - b._gen); const era = past.find((p) => p._gen >= gen); const list = era ? era.types : pokemon.types; return list.slice().sort((a, b) => a.slot - b.slot).map((t) => t.type.name); } const STAT_NAMES = [ 'hp', 'attack', 'defense', 'special-attack', 'special-defense', 'speed', ]; const NO_ENCOUNTER_DATA = new Set([ 'scarlet-violet', 'the-teal-mask', 'the-indigo-disk', 'brilliant-diamond-and-shining-pearl', 'legends-arceus', ]); export async function PokemonDetail(nationalId) { const view = el('section', { class: 'view pdetail' }); const snap = await loadSnapshot(); const st = settings.get(); const vg = snap.versionGroupByKey.get(st.versionGroup); const vgGen = vg ? vg.generation : 9; const dex = resolvePokedex(snap, st); const rows = dexRows(snap, dex); const pos = rows.findIndex((r) => r.species.id === nationalId); const prev = pos > 0 ? rows[pos - 1] : null; 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: 'view--loading' }, 'Loading Pokémon…')); let pokemon; let species; try { [pokemon, species] = await Promise.all([ getPokemon(nationalId), getSpecies(nationalId), ]); } catch (err) { clear(view).append( 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'), ), ); return view; } // Recently-viewed history for the feed strip (most recent first, capped). // Spread the previous state — the function form of set() replaces rather // than merges, so dropping `...s` here would wipe feedScroll, sort, etc. ui.set((s) => ({ ...s, recent: [nationalId, ...(s.recent || []).filter((x) => x !== nationalId)].slice(0, 12), })); // Form state: null = the base Pokémon; otherwise a snapshot form entry // whose full /pokemon data lives in `pk`. const snapSpecies = snap.speciesById.get(nationalId); const forms = (snapSpecies && snapSpecies.forms) || []; const cosmeticForms = (snapSpecies && snapSpecies.cosmeticForms) || []; let activeForm = null; // Sprite-only variant chosen from the "Appearance" picker (Unown letter, // Vivillon pattern, Furfrou trim…). Only swaps the artwork + label. let activeCosmetic = null; let pk = pokemon; let types = typesForGeneration(pk, vgGen); let mainType = types[0]; function applyThemeVars() { view.style.setProperty('--type-main', typeHex(mainType)); view.style.setProperty('--type-2', typeHex(types[1] || mainType)); view.dataset.type = mainType; } applyThemeVars(); // ---- Artwork + shiny -------------------------------------------- // Shiny Pokémon were introduced in Gen 2 — no toggle for Gen 1 games. const shinyAvailable = vgGen >= 2; const artStyle = st.spriteStyle === 'default' ? 'official' : st.spriteStyle; const boxedArt = artStyle === 'game' && vgGen <= 2; let shiny = shinyAvailable && st.showShiny; const artHolder = el('div', { class: `phero__art${boxedArt ? ' phero__art--boxed' : ''}`, }); const shinyBtn = shinyAvailable ? el('button', { class: 'phero__shiny', type: 'button', title: 'Toggle shiny', onclick: () => { shiny = !shiny; paintArt(); }, }) : null; const cryUrl = pokemon.cries?.latest || pokemon.cries?.legacy; const cryBtn = cryUrl ? el( 'button', { class: 'phero__shiny phero__cry', type: 'button', title: 'Play cry', 'aria-label': 'Play cry', onclick: () => { const a = new Audio(cryUrl); a.volume = 0.45; a.play().catch(() => {}); }, }, '►', ) : null; function paintArt() { let node; if (activeCosmetic) { // Cosmetic variants rarely have official artwork — use the pixel sprite // PokéAPI ships for the exact form, degrading to the base sprite. node = el('img', { class: 'sprite phero__pixelart', loading: 'lazy', decoding: 'async', width: 320, height: 320, alt: `${species.name} (${activeCosmetic.name})`, src: activeCosmetic.sprite || spriteUrl(nationalId, 'default'), }); node.addEventListener( 'error', () => { node.src = spriteUrl(nationalId, 'default'); }, { once: true }, ); } else { node = Sprite(activeForm ? activeForm.id : nationalId, { style: artStyle, shiny, versionGroup: st.versionGroup, alt: species.name, size: 320, }); } artHolder.replaceChildren(node); if (shinyBtn) { shinyBtn.textContent = shiny ? '✦ shiny' : '✧ shiny'; shinyBtn.classList.toggle('is-on', shiny); } } paintArt(); // ---- Seen / caught / favorite ----------------------------------- const favBtn = trackToggle('favorite', '♥'); const seenBtn = trackToggle('seen', 'Seen'); const caughtBtn = trackToggle('caught', 'Caught'); function trackToggle(field, label) { const b = el('button', { class: `ptrack__btn ptrack__btn--${field}`, type: 'button', 'aria-pressed': String(entry(nationalId)[field]), onclick: () => { toggle(nationalId, field); syncTrack(); if (entry(nationalId)[field] && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) { b.animate( [{ transform: 'scale(1)' }, { transform: 'scale(1.12)' }, { transform: 'scale(1)' }], { duration: 240, easing: 'cubic-bezier(.34,1.4,.64,1)' }, ); } }, }); b.append(label); return b; } const teamBtn = el('button', { class: 'ptrack__btn ptrack__btn--team', type: 'button', onclick: () => { if (inTeam(nationalId)) removeFromTeam(nationalId); else addToTeam(nationalId); syncTeamBtn(); }, }); function syncTeamBtn() { const on = inTeam(nationalId); const full = team.get().members.length >= MAX_TEAM; teamBtn.textContent = on ? '✓ In team' : full ? 'Team full' : '+ Team'; teamBtn.classList.toggle('is-on', on); teamBtn.disabled = !on && full; } syncTeamBtn(); function syncTrack() { for (const [field, b] of [ ['favorite', favBtn], ['seen', seenBtn], ['caught', caughtBtn], ]) { const on = entry(nationalId)[field]; b.setAttribute('aria-pressed', String(on)); b.classList.toggle('is-on', on); } } syncTrack(); const allGames = st.versionGroup === 'all'; let statBars = []; // ---- Form-dependent content (rebuilt when a form is selected) ----- function formFactsNode(p) { const abilityList = p.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 = p.stats.filter((s) => s.effort > 0).map((s) => `${s.effort} ${STAT_LABEL[s.stat.name] || s.stat.name}`).join(', ') || '—'; const heldItems = p.held_items?.map((h) => prettify(h.item.name)).join(', ') || '—'; return el( 'div', {}, el( 'dl', { class: 'pfacts' }, fact('Height', `${(p.height / 10).toFixed(1)} m`), fact('Weight', `${(p.weight / 10).toFixed(1)} kg`), abilityList.length && vgGen >= 3 ? fact('Abilities', abilitiesNode) : null, fact('Introduced', prettify(species.generation.name)), species.genera?.length ? fact('Category', (species.genera.find((g) => g.language.name === 'en') || {}).genus || '—') : null, ), el('h3', { class: 'ppanel__sub' }, 'Training'), el( 'dl', { class: 'pfacts' }, fact('EV yield', evYield), fact('Base EXP', p.base_experience ?? '—'), fact( 'Catch rate', species.capture_rate != null ? `${species.capture_rate} / 255 (~${Math.round((species.capture_rate / 255) * 100)}% max)` : '—', ), fact('Base friendship', species.base_happiness ?? '—'), fact( 'Growth rate', species.growth_rate?.name ? `${prettify(species.growth_rate.name)}${ GROWTH_EXP[species.growth_rate.name] ? ` · ${GROWTH_EXP[species.growth_rate.name].toLocaleString()} EXP` : '' }` : '—', ), fact('Held items', heldItems), ), ); } function statsContent(p) { const stat = (name) => p.stats.find((s) => s.stat.name === name)?.base_stat ?? 0; const total = STAT_NAMES.reduce((sum, n) => sum + stat(n), 0); statBars = STAT_NAMES.map((n) => StatBar(n, stat(n))); return [ el('div', { class: 'pstats' }, ...statBars), el( 'div', { class: 'statbar statbar--total' }, el('span', { class: 'statbar__label' }, 'Total'), el('span', { class: 'statbar__value' }, String(total)), el('div', { class: 'statbar__track' }), ), el('h3', { class: 'ppanel__sub' }, 'Type matchups (defending)'), TypeMatchups(types, vgGen), offensiveBlock(types, vgGen), ]; } function movesContent(p) { return allGames ? el('p', { class: 'detail__muted' }, 'Pick a game to see its learnset.') : MovesList(p.moves, { versionGroupKey: st.versionGroup, gen: vgGen, genOfVg: (name) => snap.versionGroupByKey.get(name)?.generation ?? 9, }); } // ---- About panel (FlavorText + form facts + species breeding) ---- const eggGroups = species.egg_groups.map((g) => prettify(g.name)); const hatch = species.hatch_counter; const factsHolder = el('div', {}, formFactsNode(pk)); const aboutPanel = el( 'div', { class: 'ppanel' }, FlavorText(species, vg), factsHolder, el('h3', { class: 'ppanel__sub' }, 'Breeding'), el( 'dl', { class: 'pfacts' }, el('div', { class: 'fact' }, el('dt', {}, 'Gender'), genderCell(species.gender_rate)), fact( 'Egg groups', eggGroups.includes('Undiscovered') ? "Undiscovered (can't breed)" : eggGroups.join(', ') || '—', ), fact( 'Egg cycles', hatch != null ? `${hatch} (~${((hatch + 1) * 255).toLocaleString()} steps)` : '—', ), ), ); const statsPanel = el('div', { class: 'ppanel' }, ...statsContent(pk)); // ---- Evolution panel -------------------------------------- const evoPanel = el('div', { class: 'ppanel' }, el('p', { class: 'detail__muted' }, 'Loading…')); loadEvolution(species, st, snap, vgGen).then((node) => evoPanel.replaceChildren(node)); // ---- Moves panel ---------------------------------------- const movesPanel = el('div', { class: 'ppanel' }, movesContent(pk)); // ---- Locations panel ---------------------------------- const locPanel = el('div', { class: 'ppanel' }, el('p', { class: 'detail__muted' }, 'Loading…')); if (allGames) { locPanel.replaceChildren( el('p', { class: 'detail__muted' }, 'Pick a game to see where to catch it.'), ); } else if (NO_ENCOUNTER_DATA.has(st.versionGroup)) { locPanel.replaceChildren( el('p', { class: 'detail__muted' }, "PokéAPI doesn't have wild-encounter data for this game yet."), ); } else { getEncounters(nationalId) .then((data) => locPanel.replaceChildren(Locations(data, vg ? vg.versions : []))) .catch(() => locPanel.replaceChildren(el('p', { class: 'detail__muted' }, 'Location data unavailable offline.')), ); } // ---- Form dex (Unown letters, Vivillon patterns, Alcremie decorations…) ---- // A checklist of purely-cosmetic appearances, tracked separately from the // species' own caught flag. Unlike Locations/Moves this isn't gated to the // selected game: it's the same fixed set of letters/patterns/decorations // regardless of game, and — like the rest of the app's tracking — your // progress on it is global, not per-game, so it shouldn't vanish just // because you switched your active game to browse something else. let formDexPanel = null; if (cosmeticForms.length > 1) { const slots = [ { slug: snapSpecies.name, id: nationalId, name: 'Default', sprite: null }, ...cosmeticForms, ]; const countEl = el('span', { class: 'formdex__count' }); const syncCount = () => { const n = slots.filter((f) => isFormCaught(f.slug)).length; countEl.textContent = `${n} / ${slots.length} caught`; }; const grid = el( 'div', { class: 'formdex' }, ...slots.map((f) => { const img = el('img', { class: 'sprite formdex__sprite', loading: 'lazy', decoding: 'async', alt: f.name, src: f.sprite || spriteUrl(f.id, 'default'), }); img.addEventListener( 'error', () => { img.src = spriteUrl(nationalId, 'default'); }, { once: true }, ); const tile = el( 'button', { type: 'button', class: `formdex__tile${isFormCaught(f.slug) ? ' is-caught' : ''}`, onclick: () => { toggleFormCaught(f.slug); tile.classList.toggle('is-caught', isFormCaught(f.slug)); syncCount(); }, }, img, el('span', { class: 'formdex__label' }, f.name), el('span', { class: 'formdex__check', 'aria-hidden': 'true' }, '✓'), ); return tile; }), ); syncCount(); formDexPanel = el( 'div', { class: 'ppanel' }, el( 'div', { class: 'formdex__head' }, el('h2', { class: 'ppanel__sub' }, `${prettify(snapSpecies.name)} dex`), countEl, ), el( 'p', { class: 'detail__muted' }, "Track which appearances you've caught — separate from the Pokémon's own caught flag.", ), grid, ); } // ---- Tabs ------------------------------------------- const TABS = [ { id: 'about', label: 'About', node: aboutPanel }, formDexPanel ? { id: 'formdex', label: `${prettify(snapSpecies.name)} Dex`, node: formDexPanel } : null, { id: 'stats', label: 'Stats', node: statsPanel }, { id: 'evo', label: 'Evolution', node: evoPanel }, { id: 'moves', label: 'Moves', node: movesPanel }, { id: 'loc', label: `Locations`, node: locPanel }, ].filter(Boolean); const body = el('div', { class: 'psheet__body' }); const tabButtons = TABS.map((t) => el( 'button', { class: 'psheet__tab', type: 'button', onclick: () => selectTab(t.id), }, t.label, ), ); function selectTab(id) { const tab = TABS.find((t) => t.id === id) || TABS[0]; TABS.forEach((t, i) => { const active = t.id === tab.id; tabButtons[i].classList.toggle('is-active', active); tabButtons[i].setAttribute('aria-selected', String(active)); }); body.replaceChildren(tab.node); if (tab.id === 'stats') animateStats(); ui.set({ detailTab: tab.id }); } function animateStats() { requestAnimationFrame(() => { for (const el2 of statBars) { const fill = el2.querySelector('.statbar__fill'); if (!fill || fill.dataset.animated) continue; const target = fill.style.width; fill.style.width = '0%'; requestAnimationFrame(() => { fill.style.width = target; fill.dataset.animated = '1'; }); } }); } // ---- Assemble ------------------------------------- const numLabel = regionalNumber != null ? `#${String(regionalNumber).padStart(3, '0')} · ${prettify(dex.name || dex.key)}` : `#${String(nationalId).padStart(4, '0')} · National`; const heroForm = el('span', { class: 'phero__form' }); const heroName = el( 'h1', { class: 'phero__name' }, el('span', {}, species.name.replace(/-/g, ' ')), heroForm, ); const heroTypes = el('div', { class: 'phero__types' }, ...types.map(TypeChip)); // Form switcher: Base + each mechanical form (Mega / regional / alt forme). // Selecting one re-fetches that form's /pokemon and rebuilds the // type-dependent parts of the page. let formBar = null; let formPills = []; let formOpts = []; if (forms.length) { formOpts = [{ slug: null, name: 'Base' }, ...forms]; formPills = formOpts.map((o) => el( 'button', { class: 'formbar__pill', type: 'button', onclick: () => switchForm(o.slug ? o : null), }, o.name, ), ); formPills[0].classList.add('is-active'); formBar = el('div', { class: 'formbar' }, ...formPills); } function syncFormPills() { const slug = activeForm ? activeForm.slug : null; formPills.forEach((b, i) => b.classList.toggle('is-active', formOpts[i].slug === slug)); } async function switchForm(form) { activeForm = form; activeCosmetic = null; if (cosmeticSelect) cosmeticSelect.value = ''; syncFormPills(); heroForm.textContent = form ? form.name : ''; try { pk = form ? await getPokemon(form.slug) : pokemon; } catch { pk = pokemon; } types = typesForGeneration(pk, vgGen); mainType = types[0]; applyThemeVars(); paintArt(); heroTypes.replaceChildren(...types.map(TypeChip)); factsHolder.replaceChildren(formFactsNode(pk)); statsPanel.replaceChildren(...statsContent(pk)); movesPanel.replaceChildren(movesContent(pk)); if (ui.get().detailTab === 'stats') animateStats(); } // Appearance picker: purely-cosmetic variants (Unown letters, Vivillon // patterns, Furfrou trims…). Can be 60+ per species, so one compact // dropdown rather than a row of pills. Only swaps art + label. let cosmeticBar = null; let cosmeticSelect = null; if (cosmeticForms.length) { cosmeticSelect = el( 'select', { class: 'formbar__select', 'aria-label': 'Appearance', onchange: (e) => { const slug = e.target.value; activeCosmetic = slug ? cosmeticForms.find((f) => f.slug === slug) : null; if (activeCosmetic && activeForm) { // A cosmetic look only applies to the base Pokémon; drop the // mechanical form first, then re-assert the cosmetic choice. const keep = activeCosmetic; switchForm(null); activeCosmetic = keep; cosmeticSelect.value = slug; } heroForm.textContent = activeCosmetic ? activeCosmetic.name : activeForm ? activeForm.name : ''; paintArt(); }, }, el('option', { value: '' }, `Default (${cosmeticForms.length + 1})`), ...cosmeticForms.map((f) => el('option', { value: f.slug }, f.name)), ); cosmeticBar = el( 'label', { class: 'formbar formbar--cosmetic' }, el('span', { class: 'formbar__caption' }, 'Appearance'), cosmeticSelect, ); } clear(view).append( el( 'div', { class: 'phero' }, el('div', { class: 'phero__bg' }), el('div', { class: 'phero__ball', 'aria-hidden': 'true' }), el('span', { class: 'phero__bignum', 'aria-hidden': 'true' }, `#${String(nationalId).padStart(4, '0')}`), el( 'div', { class: 'phero__top' }, el('a', { class: 'phero__back', href: '#/' }, '‹ Dex'), el('span', { class: 'phero__num' }, numLabel), el('div', { class: 'phero__actions' }, cryBtn, shinyBtn, favBtn), ), el( 'div', { class: 'phero__head' }, heroName, heroTypes, ), formBar, cosmeticBar, el( 'div', { class: 'phero__stage' }, prev ? el('a', { class: 'phero__nav phero__nav--prev', href: `#/pokemon/${prev.species.id}`, title: prev.species.name }, '‹') : el('span', { class: 'phero__nav' }), artHolder, next ? el('a', { class: 'phero__nav phero__nav--next', href: `#/pokemon/${next.species.id}`, title: next.species.name }, '›') : el('span', { class: 'phero__nav' }), ), ), el( 'div', { class: 'psheet' }, el('div', { class: 'ptrack' }, seenBtn, caughtBtn, teamBtn), el('div', { class: 'psheet__tabs', role: 'tablist' }, ...tabButtons), body, ), ); selectTab(ui.get().detailTab); // Swipe left/right to page through the current dex. const offSwipe = onSwipe(view, { onLeft: () => next && (location.hash = `#/pokemon/${next.species.id}`), onRight: () => prev && (location.hash = `#/pokemon/${prev.species.id}`), }); const off = settings.subscribe(() => syncTrack()); const offTeam = team.subscribe(syncTeamBtn); onTeardown(view, () => { off(); offTeam(); offSwipe(); }); return view; } 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)); } /** STAB offensive coverage summary for the Stats tab. */ function offensiveBlock(types, gen) { const { strong, walls } = offensiveSummary(types, gen); return el( 'div', {}, el('h3', { class: 'ppanel__sub' }, 'STAB coverage (attacking)'), el( 'div', { class: 'matchups' }, strong.length ? el( 'div', { class: 'matchups__row matchups__row--x2' }, el('span', { class: 'matchups__label' }, 'Strong vs'), el('span', { class: 'matchups__types' }, ...strong.map(TypeChip)), ) : null, walls.length ? el( 'div', { class: 'matchups__row matchups__row--half' }, el('span', { class: 'matchups__label' }, 'Walled by'), el('span', { class: 'matchups__types' }, ...walls.map(TypeChip)), ) : null, !strong.length && !walls.length ? el('p', { class: 'detail__muted' }, 'No notable STAB coverage.') : null, ), ); }