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, prefersReducedMotion } from '../store/settings.js'; import { ui } from '../store/ui.js'; import { entry, toggle, setNote } 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 { buzz } from '../lib/haptics.js'; import { countUp, fadeSlideIn } from '../lib/anim.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' : ''}`, style: 'view-transition-name: pkmn-sprite', }); 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]) { buzz(); if (!prefersReducedMotion()) { 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; } // The active mechanical form (Mega, regional, alt forme) is what gets // added — its baked types/stats drive the Team coverage/compare/calc. const teamForm = () => activeForm?.slug || null; const teamBtn = el('button', { class: 'ptrack__btn ptrack__btn--team', type: 'button', onclick: () => { if (inTeam(nationalId, teamForm())) removeFromTeam(nationalId, teamForm()); else addToTeam(nationalId, teamForm()); syncTeamBtn(); }, }); function syncTeamBtn() { const on = inTeam(nationalId, teamForm()); const full = team.get().members.length >= MAX_TEAM; const label = activeForm ? `${activeForm.name} in team` : 'In team'; teamBtn.textContent = on ? `✓ ${label}` : full ? 'Team full' : activeForm ? `+ ${activeForm.name}` : '+ 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(); // ---- Personal note ------------------------------------------------ // Autosaves (debounced) as you type, and flushes immediately on blur or // when leaving the page so a quick navigation never drops a keystroke. const noteStatus = el('span', { class: 'pnote__status', 'aria-live': 'polite' }); let noteTimer = null; let noteSaved = entry(nationalId).note || ''; function autosize(ta) { ta.style.height = 'auto'; ta.style.height = `${ta.scrollHeight}px`; } function flashSaved() { noteStatus.textContent = 'Saved'; noteStatus.classList.remove('is-visible'); void noteStatus.offsetWidth; // restart the fade noteStatus.classList.add('is-visible'); } function saveNote(value) { clearTimeout(noteTimer); if (value === noteSaved) return; noteSaved = value; setNote(nationalId, value); flashSaved(); } const noteInput = el('textarea', { class: 'pnote__input', placeholder: 'Add a note — trade plans, where you caught it, anything…', rows: 1, oninput: (e) => { autosize(e.target); clearTimeout(noteTimer); noteTimer = setTimeout(() => saveNote(e.target.value), 500); }, onfocus: (e) => autosize(e.target), onblur: (e) => saveNote(e.target.value), }); noteInput.value = noteSaved; // The textarea isn't attached (let alone laid out) yet when it's built — // this view still has to be mounted, possibly through a view transition. // Retry like the feed's scroll restore does, so a non-empty note that // wraps to several lines starts fully visible instead of clipped to one. requestAnimationFrame(() => autosize(noteInput)); setTimeout(() => autosize(noteInput), 120); if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => autosize(noteInput)); const noteBlock = el( 'div', { class: 'pnote' }, el('label', { class: 'pnote__label', for: 'pnote-input' }, 'Note', noteStatus), noteInput, ); noteInput.id = 'pnote-input'; const allGames = st.versionGroup === 'all'; let statBars = []; let statTotalNode = null; // ---- 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?.length ? el( 'span', {}, ...p.held_items.flatMap((h, i) => { const link = el( 'a', { class: 'link', href: `#/item/${idFromUrl(h.item.url)}` }, prettify(h.item.name), ); return i === 0 ? [link] : [', ', link]; }), ) : '—'; 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))); statTotalNode = el('span', { class: 'statbar__value' }, String(total)); statTotalNode.dataset.total = String(total); return [ el('div', { class: 'pstats' }, ...statBars), el( 'div', { class: 'statbar statbar--total' }, el('span', { class: 'statbar__label' }, 'Total'), statTotalNode, 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) { // For letter/pattern sets the base sprite *is* the first entry (Unown's // default artwork is the "A" shape), so give it that name rather than a // generic "Default". const DEFAULT_SLOT_NAME = { unown: 'A' }; const slots = [ { slug: snapSpecies.name, id: nationalId, name: DEFAULT_SLOT_NAME[snapSpecies.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); fadeSlideIn(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'; }); } if (statTotalNode && !statTotalNode.dataset.counted) { statTotalNode.dataset.counted = '1'; countUp(statTotalNode, Number(statTotalNode.dataset.total), { duration: 650 }); } }); } // ---- 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(); syncTeamBtn(); 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), noteBlock, 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, () => { saveNote(noteInput.value); // flush any unsaved keystrokes 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, ), ); }