From 5f6414528eb9b62d6e3afb24799cb16cafcd5160 Mon Sep 17 00:00:00 2001 From: chris Date: Wed, 9 Sep 2026 18:48:27 -0400 Subject: [PATCH] =?UTF-8?q?Compare=20any=20two=20Pok=C3=A9mon=20=E2=80=94?= =?UTF-8?q?=20standalone=20#/compare=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu --- README.md | 4 +- src/components/CompareTable.js | 74 ++++++++++++++++++++ src/router.js | 2 + src/store/ui.js | 1 + src/styles/layout.css | 4 ++ src/views/CompareView.js | 122 +++++++++++++++++++++++++++++++++ src/views/TeamView.js | 65 ++---------------- 7 files changed, 210 insertions(+), 62 deletions(-) create mode 100644 src/components/CompareTable.js create mode 100644 src/views/CompareView.js diff --git a/README.md b/README.md index ed1c03a..9ae196b 100644 --- a/README.md +++ b/README.md @@ -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 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 - 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 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, diff --git a/src/components/CompareTable.js b/src/components/CompareTable.js new file mode 100644 index 0000000..6a2a109 --- /dev/null +++ b/src/components/CompareTable.js @@ -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', + ), + ); +} diff --git a/src/router.js b/src/router.js index e43f683..9232ff2 100644 --- a/src/router.js +++ b/src/router.js @@ -9,6 +9,7 @@ import { AbilityDetail } from './views/AbilityDetail.js'; import { TeamView } from './views/TeamView.js'; import { NaturesView } from './views/NaturesView.js'; import { TypeChartView } from './views/TypeChartView.js'; +import { CompareView } from './views/CompareView.js'; import { detailSkeleton, lookupSkeleton } from './components/skeletons.js'; import { prefersReducedMotion } from './store/settings.js'; @@ -21,6 +22,7 @@ const routes = [ { pattern: /^#\/team$/, view: () => TeamView() }, { pattern: /^#\/natures$/, view: () => NaturesView() }, { pattern: /^#\/types$/, view: () => TypeChartView() }, + { pattern: /^#\/compare$/, view: () => CompareView() }, { pattern: /^#\/search$/, view: () => SearchView() }, { pattern: /^#\/settings$/, view: () => SettingsView() }, ]; diff --git a/src/store/ui.js b/src/store/ui.js index 16d38cf..c282577 100644 --- a/src/store/ui.js +++ b/src/store/ui.js @@ -20,6 +20,7 @@ export const ui = createStore('pdx.ui', { filterMinBst: 0, filterFullyEvolved: false, recent: [], + compareIds: [], toolsOpen: false, teamMode: 'coverage', mvType: '', diff --git a/src/styles/layout.css b/src/styles/layout.css index 7056722..38dcbc1 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -3010,3 +3010,7 @@ flex: none; min-width: 9rem; } + +.compareview__body { + margin-top: 16px; +} diff --git a/src/views/CompareView.js b/src/views/CompareView.js new file mode 100644 index 0000000..be77d87 --- /dev/null +++ b/src/views/CompareView.js @@ -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; +} diff --git a/src/views/TeamView.js b/src/views/TeamView.js index 3defd11..1ba1e3d 100644 --- a/src/views/TeamView.js +++ b/src/views/TeamView.js @@ -11,18 +11,11 @@ import { statAt, calcDamage } from '../lib/damage-calc.js'; import { NATURES } from '../data/natures.js'; import { Sprite } from '../components/Sprite.js'; import { TypeChip } from '../components/TypeChip.js'; +import { CompareTable } from '../components/CompareTable.js'; import { openPokemonPicker } from '../components/PokemonPicker.js'; const prettify = (s) => s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); 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) => 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}×`); @@ -75,6 +68,8 @@ export async function TeamView() { el('a', { class: 'link', href: '#/natures' }, 'Natures'), ' · ', el('a', { class: 'link', href: '#/types' }, 'Type chart'), + ' · ', + el('a', { class: 'link', href: '#/compare' }, 'Compare'), ), ), seg, @@ -335,59 +330,7 @@ export async function TeamView() { function compare(mem) { const vg = snap.versionGroupByKey.get(settings.get().versionGroup); const gen = vg ? vg.generation : 9; - 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' }, ...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', - ), - ); + return CompareTable(mem, { gen, style: settings.get().spriteStyle }); } // ---- Damage calculator ------------------------------------------