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:
parent
490ad9b7f1
commit
5f6414528e
@ -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,
|
||||||
|
|||||||
74
src/components/CompareTable.js
Normal file
74
src/components/CompareTable.js
Normal 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',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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() },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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: '',
|
||||||
|
|||||||
@ -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
122
src/views/CompareView.js
Normal 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;
|
||||||
|
}
|
||||||
@ -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 ------------------------------------------
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user