dex/src/views/PokemonDetail.js
chris 671e72972a Apply Prettier to the whole tree
Pure formatting — no behaviour change. Verified: build passes, all 14
routes render, service worker active, no console errors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-10 11:24:31 -04:00

892 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
),
);
}