Compare any two Pokémon — standalone #/compare view

Extracted the Team Compare table into components/CompareTable.js and
reused it for a new #/compare route: its own picker (up to 4 Pokémon,
persisted in ui.compareIds), gen-aware types, per-stat max highlight.
Linked from the Team header alongside Natures / Type chart. TeamView's
Compare tab now just calls CompareTable too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
chris 2026-09-09 18:48:27 -04:00
parent 490ad9b7f1
commit 5f6414528e
7 changed files with 210 additions and 62 deletions

View File

@ -111,7 +111,9 @@ Working app with a type-themed UI:
summary (types that hit 2+ members, or that someone's weak to and nobody summary (types that hit 2+ members, or that someone's weak to and nobody
resists), then a full matchup grid with a diverging resist◀│▶weak bar per resists), then a full matchup grid with a diverging resist◀│▶weak bar per
type; plus offensive STAB gaps and at-a-glance stats. A Compare table type; plus offensive STAB gaps and at-a-glance stats. A Compare table
stacks the six side by side. A third Calc mode is a damage calculator — stacks the six side by side (the same table is also a standalone
`#/compare` view with its own picker — up to 4 Pokémon, persisted).
A third Calc mode is a damage calculator —
pick (or quick-pick from your team) an attacker + move + defender, and pick (or quick-pick from your team) an attacker + move + defender, and
get a damage range, % of HP, hits-to-KO, STAB/effectiveness badges, with get a damage range, % of HP, hits-to-KO, STAB/effectiveness badges, with
level/nature/EV controls; it's a base-stats estimate (31 IVs assumed, level/nature/EV controls; it's a base-stats estimate (31 IVs assumed,

View File

@ -0,0 +1,74 @@
import { el } from '../lib/dom.js';
import { Sprite } from './Sprite.js';
import { TypeChip } from './TypeChip.js';
import { typesForGen } from '../lib/type-resolve.js';
const STAT_ROWS = [
['hp', 'HP'],
['atk', 'Attack'],
['def', 'Defense'],
['spa', 'Sp. Atk'],
['spd', 'Sp. Def'],
['spe', 'Speed'],
];
/**
* Side-by-side stat / type comparison of 2+ snapshot species. Each stat
* row highlights the highest value; types are era-accurate for `gen`.
*/
export function CompareTable(members, { gen = 9, style } = {}) {
const mem = members;
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, { style, size: 44, alt: sp.name }),
el('span', {}, sp.name.replace(/-/g, ' ')),
),
),
'cmp__row--head',
),
row(
'Type',
mem.map((sp) => el('span', { class: 'cmp__cell' }, ...typesForGen(sp, gen).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',
),
);
}

View File

@ -9,6 +9,7 @@ import { AbilityDetail } from './views/AbilityDetail.js';
import { TeamView } from './views/TeamView.js'; import { TeamView } from './views/TeamView.js';
import { NaturesView } from './views/NaturesView.js'; import { NaturesView } from './views/NaturesView.js';
import { TypeChartView } from './views/TypeChartView.js'; import { TypeChartView } from './views/TypeChartView.js';
import { CompareView } from './views/CompareView.js';
import { detailSkeleton, lookupSkeleton } from './components/skeletons.js'; import { detailSkeleton, lookupSkeleton } from './components/skeletons.js';
import { prefersReducedMotion } from './store/settings.js'; import { prefersReducedMotion } from './store/settings.js';
@ -21,6 +22,7 @@ const routes = [
{ pattern: /^#\/team$/, view: () => TeamView() }, { pattern: /^#\/team$/, view: () => TeamView() },
{ pattern: /^#\/natures$/, view: () => NaturesView() }, { pattern: /^#\/natures$/, view: () => NaturesView() },
{ pattern: /^#\/types$/, view: () => TypeChartView() }, { pattern: /^#\/types$/, view: () => TypeChartView() },
{ pattern: /^#\/compare$/, view: () => CompareView() },
{ pattern: /^#\/search$/, view: () => SearchView() }, { pattern: /^#\/search$/, view: () => SearchView() },
{ pattern: /^#\/settings$/, view: () => SettingsView() }, { pattern: /^#\/settings$/, view: () => SettingsView() },
]; ];

View File

@ -20,6 +20,7 @@ export const ui = createStore('pdx.ui', {
filterMinBst: 0, filterMinBst: 0,
filterFullyEvolved: false, filterFullyEvolved: false,
recent: [], recent: [],
compareIds: [],
toolsOpen: false, toolsOpen: false,
teamMode: 'coverage', teamMode: 'coverage',
mvType: '', mvType: '',

View File

@ -3010,3 +3010,7 @@
flex: none; flex: none;
min-width: 9rem; min-width: 9rem;
} }
.compareview__body {
margin-top: 16px;
}

122
src/views/CompareView.js Normal file
View File

@ -0,0 +1,122 @@
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 { Sprite } from '../components/Sprite.js';
import { CompareTable } from '../components/CompareTable.js';
import { openPokemonPicker } from '../components/PokemonPicker.js';
const MAX = 4;
export async function CompareView() {
const view = el('section', { class: 'view lookup compareview' });
const snap = await loadSnapshot();
const vg = snap.versionGroupByKey.get(settings.get().versionGroup);
const gen = vg ? vg.generation : 9;
let ids = (ui.get().compareIds || []).filter((id) => snap.speciesById.get(id)).slice(0, MAX);
const persist = () => ui.set({ compareIds: ids });
const slots = el('div', { class: 'lineup' });
const body = el('div', { class: 'compareview__body' });
function render() {
const style = settings.get().spriteStyle;
const mem = ids.map((id) => snap.speciesById.get(id)).filter(Boolean);
clear(slots);
for (const sp of mem) {
slots.append(
el(
'div',
{ class: 'lineup__slot' },
el(
'button',
{
class: 'lineup__x',
type: 'button',
'aria-label': `Remove ${sp.name}`,
onclick: () => {
ids = ids.filter((x) => x !== sp.id);
persist();
render();
},
},
'✕',
),
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) {
slots.append(
el(
'button',
{
class: 'lineup__add',
type: 'button',
onclick: () =>
openPokemonPicker((id) => {
if (ids.includes(id) || ids.length >= MAX) return;
ids = [...ids, id];
persist();
render();
}),
},
'',
el('span', {}, 'Add'),
),
);
}
if (mem.length) {
slots.append(
el(
'button',
{
class: 'lineup__clear',
type: 'button',
onclick: () => {
ids = [];
persist();
render();
},
},
'Clear',
),
);
}
clear(body);
body.append(
mem.length >= 2
? CompareTable(mem, { gen, style })
: el('p', { class: 'detail__muted' }, 'Add two or more Pokémon to compare their types and base stats.'),
);
}
clear(view).append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/team' }, ' Team')),
el(
'header',
{ class: 'view__header' },
el('h1', {}, 'Compare'),
el(
'p',
{},
vg ? `Types shown for ${vg.name}.` : 'Types shown for the newest games.',
),
),
slots,
body,
);
render();
const offSettings = settings.subscribe(render);
onTeardown(view, () => offSettings());
return view;
}

View File

@ -11,18 +11,11 @@ import { statAt, calcDamage } from '../lib/damage-calc.js';
import { NATURES } from '../data/natures.js'; import { NATURES } from '../data/natures.js';
import { Sprite } from '../components/Sprite.js'; import { Sprite } from '../components/Sprite.js';
import { TypeChip } from '../components/TypeChip.js'; import { TypeChip } from '../components/TypeChip.js';
import { CompareTable } from '../components/CompareTable.js';
import { openPokemonPicker } from '../components/PokemonPicker.js'; import { openPokemonPicker } from '../components/PokemonPicker.js';
const prettify = (s) => s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); const prettify = (s) => s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const loose = (s) => s.toLowerCase().replace(/[-\s]+/g, ' ').trim(); const loose = (s) => s.toLowerCase().replace(/[-\s]+/g, ' ').trim();
const STAT_ROWS = [
['hp', 'HP'],
['atk', 'Attack'],
['def', 'Defense'],
['spa', 'Sp. Atk'],
['spd', 'Sp. Def'],
['spe', 'Speed'],
];
const multClass = (m) => const multClass = (m) =>
m === 0 ? 'm0' : m === 0.25 ? 'm025' : m === 0.5 ? 'm05' : m === 2 ? 'm2' : m === 4 ? 'm4' : 'm1'; 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}×`); const multText = (m) => (m === 0.25 ? '¼' : m === 0.5 ? '½' : m === 1 ? '' : `${m}×`);
@ -75,6 +68,8 @@ export async function TeamView() {
el('a', { class: 'link', href: '#/natures' }, 'Natures'), el('a', { class: 'link', href: '#/natures' }, 'Natures'),
' · ', ' · ',
el('a', { class: 'link', href: '#/types' }, 'Type chart'), el('a', { class: 'link', href: '#/types' }, 'Type chart'),
' · ',
el('a', { class: 'link', href: '#/compare' }, 'Compare'),
), ),
), ),
seg, seg,
@ -335,59 +330,7 @@ export async function TeamView() {
function compare(mem) { function compare(mem) {
const vg = snap.versionGroupByKey.get(settings.get().versionGroup); const vg = snap.versionGroupByKey.get(settings.get().versionGroup);
const gen = vg ? vg.generation : 9; const gen = vg ? vg.generation : 9;
const maxOf = (key) => Math.max(...mem.map((m) => m.stats?.[key] ?? 0)); return CompareTable(mem, { gen, style: settings.get().spriteStyle });
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' }, ...typesForGen(sp, gen).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',
),
);
} }
// ---- Damage calculator ------------------------------------------ // ---- Damage calculator ------------------------------------------