- 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>
284 lines
8.3 KiB
JavaScript
284 lines
8.3 KiB
JavaScript
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));
|
||
}
|