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
892 lines
29 KiB
JavaScript
892 lines
29 KiB
JavaScript
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,
|
||
),
|
||
);
|
||
}
|