dex/src/views/PokemonDetail.js
chris 1adc06d11f Un-gate the form dex from the selected game
Second thought on the previous commit: gating "<Species> Dex" to games
that include the species contradicted the app's own model. Caught/seen
tracking here is unified across games by design, and there's no
game-dependent data on this tab (unlike Locations/Moves, which really do
vary by game and are rightly gated) — it's the same fixed set of
letters/patterns/decorations regardless of which game is selected. Hiding
it just because you switched games to browse something else would make
already-tracked progress look lost. Show it whenever the species has
cosmetic forms, full stop.

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

769 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. Unlike Locations/Moves this isn't gated to the
// selected game: it's the same fixed set of letters/patterns/decorations
// regardless of game, and — like the rest of the app's tracking — your
// progress on it is global, not per-game, so it shouldn't vanish just
// because you switched your active game to browse something else.
let formDexPanel = null;
if (cosmeticForms.length > 1) {
const slots = [
{ slug: snapSpecies.name, id: nationalId, name: 'Default', sprite: null },
...cosmeticForms,
];
const countEl = el('span', { class: 'formdex__count' });
const syncCount = () => {
const n = slots.filter((f) => isFormCaught(f.slug)).length;
countEl.textContent = `${n} / ${slots.length} caught`;
};
const grid = el(
'div',
{ class: 'formdex' },
...slots.map((f) => {
const img = el('img', {
class: 'sprite formdex__sprite',
loading: 'lazy',
decoding: 'async',
alt: f.name,
src: f.sprite || spriteUrl(f.id, 'default'),
});
img.addEventListener(
'error',
() => {
img.src = spriteUrl(nationalId, 'default');
},
{ once: true },
);
const tile = el(
'button',
{
type: 'button',
class: `formdex__tile${isFormCaught(f.slug) ? ' is-caught' : ''}`,
onclick: () => {
toggleFormCaught(f.slug);
tile.classList.toggle('is-caught', isFormCaught(f.slug));
syncCount();
},
},
img,
el('span', { class: 'formdex__label' }, f.name),
el('span', { class: 'formdex__check', 'aria-hidden': 'true' }, '✓'),
);
return tile;
}),
);
syncCount();
formDexPanel = el(
'div',
{ class: 'ppanel' },
el(
'div',
{ class: 'formdex__head' },
el('h2', { class: 'ppanel__sub' }, `${prettify(snapSpecies.name)} dex`),
countEl,
),
el(
'p',
{ class: 'detail__muted' },
"Track which appearances you've caught — separate from the Pokémon's own caught flag.",
),
grid,
);
}
// ---- Tabs -------------------------------------------
const TABS = [
{ id: 'about', label: 'About', node: aboutPanel },
formDexPanel ? { id: 'formdex', label: `${prettify(snapSpecies.name)} Dex`, node: formDexPanel } : null,
{ id: 'stats', label: 'Stats', node: statsPanel },
{ id: 'evo', label: 'Evolution', node: evoPanel },
{ id: 'moves', label: 'Moves', node: movesPanel },
{ id: 'loc', label: `Locations`, node: locPanel },
].filter(Boolean);
const body = el('div', { class: 'psheet__body' });
const tabButtons = TABS.map((t) =>
el(
'button',
{
class: 'psheet__tab',
type: 'button',
onclick: () => selectTab(t.id),
},
t.label,
),
);
function selectTab(id) {
const tab = TABS.find((t) => t.id === id) || TABS[0];
TABS.forEach((t, i) => {
const active = t.id === tab.id;
tabButtons[i].classList.toggle('is-active', active);
tabButtons[i].setAttribute('aria-selected', String(active));
});
body.replaceChildren(tab.node);
if (tab.id === 'stats') animateStats();
ui.set({ detailTab: tab.id });
}
function animateStats() {
requestAnimationFrame(() => {
for (const el2 of statBars) {
const fill = el2.querySelector('.statbar__fill');
if (!fill || fill.dataset.animated) continue;
const target = fill.style.width;
fill.style.width = '0%';
requestAnimationFrame(() => {
fill.style.width = target;
fill.dataset.animated = '1';
});
}
});
}
// ---- Assemble -------------------------------------
const numLabel =
regionalNumber != null
? `#${String(regionalNumber).padStart(3, '0')} · ${prettify(dex.name || dex.key)}`
: `#${String(nationalId).padStart(4, '0')} · National`;
const heroForm = el('span', { class: 'phero__form' });
const heroName = el(
'h1',
{ class: 'phero__name' },
el('span', {}, species.name.replace(/-/g, ' ')),
heroForm,
);
const heroTypes = el('div', { class: 'phero__types' }, ...types.map(TypeChip));
// Form switcher: Base + each mechanical form (Mega / regional / alt forme).
// Selecting one re-fetches that form's /pokemon and rebuilds the
// type-dependent parts of the page.
let formBar = null;
let formPills = [];
let formOpts = [];
if (forms.length) {
formOpts = [{ slug: null, name: 'Base' }, ...forms];
formPills = formOpts.map((o) =>
el(
'button',
{
class: 'formbar__pill',
type: 'button',
onclick: () => switchForm(o.slug ? o : null),
},
o.name,
),
);
formPills[0].classList.add('is-active');
formBar = el('div', { class: 'formbar' }, ...formPills);
}
function syncFormPills() {
const slug = activeForm ? activeForm.slug : null;
formPills.forEach((b, i) => b.classList.toggle('is-active', formOpts[i].slug === slug));
}
async function switchForm(form) {
activeForm = form;
activeCosmetic = null;
if (cosmeticSelect) cosmeticSelect.value = '';
syncFormPills();
heroForm.textContent = form ? form.name : '';
try {
pk = form ? await getPokemon(form.slug) : pokemon;
} catch {
pk = pokemon;
}
types = typesForGeneration(pk, vgGen);
mainType = types[0];
applyThemeVars();
paintArt();
heroTypes.replaceChildren(...types.map(TypeChip));
factsHolder.replaceChildren(formFactsNode(pk));
statsPanel.replaceChildren(...statsContent(pk));
movesPanel.replaceChildren(movesContent(pk));
if (ui.get().detailTab === 'stats') animateStats();
}
// Appearance picker: purely-cosmetic variants (Unown letters, Vivillon
// patterns, Furfrou trims…). Can be 60+ per species, so one compact
// dropdown rather than a row of pills. Only swaps art + label.
let cosmeticBar = null;
let cosmeticSelect = null;
if (cosmeticForms.length) {
cosmeticSelect = el(
'select',
{
class: 'formbar__select',
'aria-label': 'Appearance',
onchange: (e) => {
const slug = e.target.value;
activeCosmetic = slug ? cosmeticForms.find((f) => f.slug === slug) : null;
if (activeCosmetic && activeForm) {
// A cosmetic look only applies to the base Pokémon; drop the
// mechanical form first, then re-assert the cosmetic choice.
const keep = activeCosmetic;
switchForm(null);
activeCosmetic = keep;
cosmeticSelect.value = slug;
}
heroForm.textContent = activeCosmetic
? activeCosmetic.name
: activeForm
? activeForm.name
: '';
paintArt();
},
},
el('option', { value: '' }, `Default (${cosmeticForms.length + 1})`),
...cosmeticForms.map((f) => el('option', { value: f.slug }, f.name)),
);
cosmeticBar = el(
'label',
{ class: 'formbar formbar--cosmetic' },
el('span', { class: 'formbar__caption' }, 'Appearance'),
cosmeticSelect,
);
}
clear(view).append(
el(
'div',
{ class: 'phero' },
el('div', { class: 'phero__bg' }),
el('div', { class: 'phero__ball', 'aria-hidden': 'true' }),
el('span', { class: 'phero__bignum', 'aria-hidden': 'true' }, `#${String(nationalId).padStart(4, '0')}`),
el(
'div',
{ class: 'phero__top' },
el('a', { class: 'phero__back', href: '#/' }, ' Dex'),
el('span', { class: 'phero__num' }, numLabel),
el('div', { class: 'phero__actions' }, cryBtn, shinyBtn, favBtn),
),
el(
'div',
{ class: 'phero__head' },
heroName,
heroTypes,
),
formBar,
cosmeticBar,
el(
'div',
{ class: 'phero__stage' },
prev
? el('a', { class: 'phero__nav phero__nav--prev', href: `#/pokemon/${prev.species.id}`, title: prev.species.name }, '')
: el('span', { class: 'phero__nav' }),
artHolder,
next
? el('a', { class: 'phero__nav phero__nav--next', href: `#/pokemon/${next.species.id}`, title: next.species.name }, '')
: el('span', { class: 'phero__nav' }),
),
),
el(
'div',
{ class: 'psheet' },
el('div', { class: 'ptrack' }, seenBtn, caughtBtn, teamBtn),
el('div', { class: 'psheet__tabs', role: 'tablist' }, ...tabButtons),
body,
),
);
selectTab(ui.get().detailTab);
// Swipe left/right to page through the current dex.
const offSwipe = onSwipe(view, {
onLeft: () => next && (location.hash = `#/pokemon/${next.species.id}`),
onRight: () => prev && (location.hash = `#/pokemon/${prev.species.id}`),
});
const off = settings.subscribe(() => syncTrack());
const offTeam = team.subscribe(syncTeamBtn);
onTeardown(view, () => {
off();
offTeam();
offSwipe();
});
return view;
}
async function loadEvolution(species, st, snap, vgGen) {
if (!species.evolution_chain?.url) {
return el('p', { class: 'detail__muted' }, 'Does not evolve.');
}
try {
const chain = await getEvolutionChain(idFromUrl(species.evolution_chain.url));
return EvolutionChain(chain, {
style: st.spriteStyle === 'default' ? 'default' : st.spriteStyle,
versionGroup: st.versionGroup,
maxGen: vgGen,
genOf: (id) => snap.speciesById.get(id)?.generation ?? 1,
});
} catch {
return el('p', { class: 'detail__muted' }, 'Evolution data unavailable offline.');
}
}
function fact(label, value) {
return el('div', { class: 'fact' }, el('dt', {}, label), el('dd', {}, value));
}
/** STAB offensive coverage summary for the Stats tab. */
function offensiveBlock(types, gen) {
const { strong, walls } = offensiveSummary(types, gen);
return el(
'div',
{},
el('h3', { class: 'ppanel__sub' }, 'STAB coverage (attacking)'),
el(
'div',
{ class: 'matchups' },
strong.length
? el(
'div',
{ class: 'matchups__row matchups__row--x2' },
el('span', { class: 'matchups__label' }, 'Strong vs'),
el('span', { class: 'matchups__types' }, ...strong.map(TypeChip)),
)
: null,
walls.length
? el(
'div',
{ class: 'matchups__row matchups__row--half' },
el('span', { class: 'matchups__label' }, 'Walled by'),
el('span', { class: 'matchups__types' }, ...walls.map(TypeChip)),
)
: null,
!strong.length && !walls.length
? el('p', { class: 'detail__muted' }, 'No notable STAB coverage.')
: null,
),
);
}