dex/src/views/PokemonDetail.js
chris 1ed8fcb709 Add a per-species form dex (Unown letters, Vivillon patterns, ...)
New tab on the detail page — "<Species> Dex" — for any species with more
than one cosmetic form: a checklist grid (sprite + label + tap-to-toggle)
tracking which appearances you've caught, separate from the species' own
caught flag. Persisted in a new pdx.formTracking store keyed by form slug.

Only offered when the species is actually in the currently selected
game's dex (reuses the page's existing prev/next row lookup), so Unown
gets its dex in HeartGold/SoulSilver but not in Sword/Shield, where it
was never released.

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

767 lines
25 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 } 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. Only offered when this Pokémon is actually in
// the currently selected game's dex (`pos >= 0`) — no Vivillon checklist
// for a game it was never released in.
let formDexPanel = null;
if (cosmeticForms.length > 1 && pos >= 0) {
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,
),
);
}