dex/src/views/TeamView.js
chris 415cf3edec Abilities lookup + detail; Natures reference
- Snapshot bakes an abilities index (373: name, generation, effect, and the
  species that have it). Snapshot ~872 KB / 133 KB gzip.
- Search gains an 'Abilities' tab — browse all 373 offline, filter/search by
  name or effect, sort A–Z / most-Pokemon / newest
- #/ability/:id detail: effect + a grid of every Pokemon with the ability
  (all from the snapshot, no fetch)
- Pokemon detail: the Abilities line is now tappable, linking to each ability
- #/natures: the full 25-nature +10%/-10% table; linked from the Team view

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 11:00:14 -04:00

284 lines
8.3 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 { loadSnapshot } from '../data/snapshot.js';
import { settings } from '../store/settings.js';
import { ui } from '../store/ui.js';
import { team, addToTeam, removeFromTeam, clearTeam, MAX_TEAM } from '../store/team.js';
import { TYPES, defenseVector, multiplier } from '../data/type-chart.js';
import { Sprite } from '../components/Sprite.js';
import { TypeChip } from '../components/TypeChip.js';
import { openPokemonPicker } from '../components/PokemonPicker.js';
const prettify = (s) => s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const STAT_ROWS = [
['hp', 'HP'],
['atk', 'Attack'],
['def', 'Defense'],
['spa', 'Sp. Atk'],
['spd', 'Sp. Def'],
['spe', 'Speed'],
];
const multClass = (m) =>
m === 0 ? 'm0' : m === 0.25 ? 'm025' : m === 0.5 ? 'm05' : m === 2 ? 'm2' : m === 4 ? 'm4' : 'm1';
const multText = (m) => (m === 0.25 ? '¼' : m === 0.5 ? '½' : m === 1 ? '' : `${m}×`);
export async function TeamView() {
const view = el('section', { class: 'view teamview' });
const snap = await loadSnapshot();
let mode = ui.get().teamMode === 'compare' ? 'compare' : 'coverage';
const seg = el(
'div',
{ class: 'seg' },
...[
['coverage', 'Coverage'],
['compare', 'Compare'],
].map(([id, label]) =>
el(
'button',
{
class: `seg__btn${id === mode ? ' is-active' : ''}`,
type: 'button',
onclick: () => {
mode = id;
ui.set({ teamMode: id });
[...seg.children].forEach((b, i) =>
b.classList.toggle('is-active', ['coverage', 'compare'][i] === id),
);
render();
},
},
label,
),
),
);
const lineup = el('div', { class: 'lineup' });
const body = el('div', { class: 'teamview__body' });
view.append(
el(
'header',
{ class: 'view__header' },
el('h1', {}, 'Team'),
el(
'p',
{},
`Up to ${MAX_TEAM} Pokémon — check weaknesses, coverage and stats. `,
el('a', { class: 'link', href: '#/natures' }, 'Natures reference'),
),
),
seg,
lineup,
body,
);
function members() {
return team.get().members.map((id) => snap.speciesById.get(id)).filter(Boolean);
}
function renderLineup() {
const mem = members();
clear(lineup);
const style = settings.get().spriteStyle;
for (const sp of mem) {
lineup.append(
el(
'div',
{ class: 'lineup__slot' },
el(
'button',
{
class: 'lineup__x',
type: 'button',
'aria-label': `Remove ${sp.name}`,
onclick: () => {
removeFromTeam(sp.id);
},
},
'✕',
),
el(
'a',
{ href: `#/pokemon/${sp.id}`, class: 'lineup__link' },
Sprite(sp.id, { style, alt: sp.name, size: 64 }),
el('span', { class: 'lineup__name' }, sp.name.replace(/-/g, ' ')),
),
),
);
}
if (mem.length < MAX_TEAM) {
lineup.append(
el(
'button',
{
class: 'lineup__add',
type: 'button',
onclick: () => openPokemonPicker(addToTeam),
},
'',
el('span', {}, 'Add'),
),
);
}
if (mem.length) {
lineup.append(
el(
'button',
{ class: 'lineup__clear', type: 'button', onclick: () => clearTeam() },
'Clear',
),
);
}
}
function render() {
renderLineup();
const mem = members();
clear(body);
if (!mem.length) {
body.append(el('p', { class: 'detail__muted' }, 'Add some Pokémon to get started.'));
return;
}
body.append(mode === 'coverage' ? coverage(mem) : compare(mem));
}
function coverage(mem) {
const gen = 9;
const vectors = mem.map((sp) => defenseVector(sp.types, gen));
// Defensive weakness table
const head = el(
'div',
{ class: 'cov__row cov__row--head' },
el('span', {}, ''),
...mem.map((sp) => el('span', { class: 'cov__mem' }, Sprite(sp.id, { size: 34, alt: sp.name }))),
el('span', { class: 'cov__sum' }, 'Weak'),
);
const rows = TYPES.map((t) => {
let weak = 0;
const cells = vectors.map((v) => {
const m = v[t] ?? 1;
if (m >= 2) weak += 1;
return el('span', { class: `cov__cell ${multClass(m)}` }, multText(m));
});
return el(
'div',
{ class: 'cov__row' },
el('span', { class: 'cov__type' }, TypeChip(t)),
...cells,
el('span', { class: `cov__sum${weak >= 2 ? ' is-bad' : ''}` }, weak || ''),
);
});
// Offensive coverage gaps: defending types no member hits super-effectively
const gaps = TYPES.filter((def) => {
if (gen < 6 && def === 'fairy') return false;
return !mem.some((sp) => sp.types.some((atk) => multiplier(atk, [def], gen) >= 2));
});
// Quick team stats
const avgBst = Math.round(mem.reduce((s, m) => s + (m.bst || 0), 0) / mem.length);
const fastest = mem.slice().sort((a, b) => (b.stats?.spe || 0) - (a.stats?.spe || 0))[0];
const bulkiest = mem
.slice()
.sort(
(a, b) =>
(b.stats?.hp || 0) + (b.stats?.def || 0) + (b.stats?.spd || 0) -
((a.stats?.hp || 0) + (a.stats?.def || 0) + (a.stats?.spd || 0)),
)[0];
return el(
'div',
{},
el('h2', { class: 'ppanel__sub' }, 'Type coverage — how each type hits your team'),
el('div', { class: 'cov' }, head, ...rows),
el('h2', { class: 'ppanel__sub' }, 'Offensive gaps (no super-effective STAB)'),
gaps.length
? el('div', { class: 'matchups__types' }, ...gaps.map(TypeChip))
: el('p', { class: 'detail__muted' }, 'Your team hits every type super-effectively. Nice.'),
el('h2', { class: 'ppanel__sub' }, 'At a glance'),
el(
'dl',
{ class: 'pfacts' },
fact('Average BST', String(avgBst)),
fact('Fastest', `${prettify(fastest.name)} (${fastest.stats?.spe ?? '?'} Spe)`),
fact(
'Bulkiest',
`${prettify(bulkiest.name)} (${
(bulkiest.stats?.hp || 0) + (bulkiest.stats?.def || 0) + (bulkiest.stats?.spd || 0)
} HP+Def+SpD)`,
),
),
);
}
function compare(mem) {
const maxOf = (key) => Math.max(...mem.map((m) => m.stats?.[key] ?? 0));
const row = (label, cells, cls = '') =>
el('div', { class: `cmp__row ${cls}` }, el('span', { class: 'cmp__label' }, label), ...cells);
return el(
'div',
{ class: 'cmp' },
row(
'',
mem.map((sp) =>
el(
'a',
{ class: 'cmp__mem', href: `#/pokemon/${sp.id}` },
Sprite(sp.id, { size: 44, alt: sp.name }),
el('span', {}, sp.name.replace(/-/g, ' ')),
),
),
'cmp__row--head',
),
row(
'Type',
mem.map((sp) => el('span', { class: 'cmp__cell' }, ...sp.types.map(TypeChip))),
),
...STAT_ROWS.map(([key, label]) => {
const mx = maxOf(key);
return row(
label,
mem.map((sp) => {
const v = sp.stats?.[key] ?? 0;
const bar = el('span', { class: 'cmp__bar' });
bar.style.width = `${(v / 200) * 100}%`;
return el(
'span',
{ class: `cmp__cell cmp__stat${v === mx ? ' is-max' : ''}` },
el('span', { class: 'cmp__val' }, String(v)),
el('span', { class: 'cmp__track' }, bar),
);
}),
);
}),
row(
'BST',
mem.map((sp) => {
const mx = Math.max(...mem.map((m) => m.bst || 0));
return el(
'span',
{ class: `cmp__cell cmp__stat${(sp.bst || 0) === mx ? ' is-max' : ''}` },
el('span', { class: 'cmp__val' }, String(sp.bst || 0)),
);
}),
'cmp__row--bst',
),
);
}
render();
const off = team.subscribe(render);
const offStyle = settings.subscribe(renderLineup);
onTeardown(view, () => {
off();
offStyle();
});
return view;
}
function fact(k, v) {
return el('div', { class: 'fact' }, el('dt', {}, k), el('dd', {}, v));
}