Add moves, evolution, type matchups, locations; game-accurate data; fix desktop layout

- Detail page: base stats, type matchups (defending), evolution chain,
  learnset with per-move power/type/acc/PP/effect, wild locations
- Game-aware: abilities gated to Gen 3+ (hidden to Gen 5+), type chart
  applies Gen 1 / pre-Gen 6 rules, move stats use PokeAPI past_values,
  pre-Gen 4 physical/special-by-type, evolution tree re-parented to the
  selected generation
- Moves and evolution clickable/expandable; no horizontal scroll
- Fix: .view-host shrink-wrapped as a grid item on desktop (margin:0 auto)
- Era-accurate sprite option; Search tab relabelled as global lookup

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
chris 2026-08-27 11:35:30 -04:00
parent cd2655011f
commit f1abe07128
17 changed files with 1124 additions and 111 deletions

25
package-lock.json generated
View File

@ -18,7 +18,8 @@
},
"devDependencies": {
"vite": "^5.4.21",
"vite-plugin-pwa": "^0.21.2"
"vite-plugin-pwa": "^0.21.2",
"ws": "^8.21.3"
}
},
"node_modules/@apideck/better-ajv-errors": {
@ -6181,6 +6182,28 @@
"workbox-core": "7.4.1"
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@ -14,7 +14,8 @@
"license": "MIT",
"devDependencies": {
"vite": "^5.4.21",
"vite-plugin-pwa": "^0.21.2"
"vite-plugin-pwa": "^0.21.2",
"ws": "^8.21.3"
},
"dependencies": {
"workbox-cacheable-response": "^7.4.1",

View File

@ -8,7 +8,7 @@ import { entry, toggle } from '../store/selection.js';
* caught toggle is a real button layered on top and updates itself in place
* so toggling never repaints the whole grid.
*/
export function Card(species, number, { spriteStyle = 'default' } = {}) {
export function Card(species, number, { spriteStyle = 'default', versionGroup } = {}) {
const state = entry(species.id);
const caughtBtn = el('button', {
@ -33,7 +33,7 @@ export function Card(species, number, { spriteStyle = 'default' } = {}) {
href: `#/pokemon/${species.id}`,
},
el('span', { class: 'card__num' }, `#${String(number ?? species.id).padStart(4, '0')}`),
Sprite(species.id, { style: spriteStyle, alt: species.name, size: 96 }),
Sprite(species.id, { style: spriteStyle, versionGroup, alt: species.name, size: 96 }),
el('span', { class: 'card__name' }, species.name.replace(/-/g, ' ')),
el(
'span',

View File

@ -0,0 +1,111 @@
import { el } from '../lib/dom.js';
import { Sprite } from './Sprite.js';
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
/** Human-readable trigger for one evolution step. */
function describe(details) {
if (!details || !details.length) return '';
const d = details[0];
if (d.min_level) return `Lv. ${d.min_level}`;
if (d.item) return `use ${d.item.name.replace(/-/g, ' ')}`;
if (d.trigger?.name === 'trade') {
return d.held_item ? `trade w/ ${d.held_item.name.replace(/-/g, ' ')}` : 'trade';
}
if (d.min_happiness) return 'high friendship';
if (d.known_move_type) return `knows a ${d.known_move_type.name} move`;
if (d.trigger?.name) return d.trigger.name.replace(/-/g, ' ');
return '';
}
function node(stage, style, versionGroup) {
return el(
'a',
{ class: 'evo__node', href: `#/pokemon/${stage.id}` },
Sprite(stage.id, { style, versionGroup, alt: stage.name, size: 72 }),
el('span', { class: 'evo__name' }, stage.name.replace(/-/g, ' ')),
);
}
/**
* PokéAPI returns the full modern evolution tree. Re-parent it around the
* selected game's generation: stages introduced later (cross-gen evos, later
* babies) are dropped, and their in-era descendants attach to the nearest
* surviving ancestor so Pikachu Raichu still shows in Red/Blue even
* though Pichu doesn't, and Bellossom never appears there.
*/
export function EvolutionChain(chain, { style = 'default', versionGroup, genOf = () => 1, maxGen = 9 } = {}) {
const nodes = new Map();
let pruned = false;
const collect = (link, parentId) => {
const id = idFromUrl(link.species.url);
let currentId = parentId;
if (genOf(id) <= maxGen) {
if (!nodes.has(id)) {
nodes.set(id, {
id,
name: link.species.name,
parent: parentId,
via: parentId != null ? describe(link.evolution_details) : '',
kids: [],
});
}
currentId = id;
} else {
pruned = true;
}
for (const child of link.evolves_to) collect(child, currentId);
};
collect(chain.chain, null);
for (const n of nodes.values()) {
if (n.parent != null && nodes.has(n.parent)) nodes.get(n.parent).kids.push(n.id);
}
if (nodes.size <= 1) {
return el(
'p',
{ class: 'detail__muted' },
pruned ? 'No evolutions available in this game.' : 'Does not evolve.',
);
}
const roots = [...nodes.values()].filter((n) => n.parent == null || !nodes.has(n.parent));
const rows = [];
const walkPaths = (n, acc) => {
const path = [...acc, n];
if (!n.kids.length) rows.push(path);
else for (const k of n.kids) walkPaths(nodes.get(k), path);
};
roots.forEach((r) => walkPaths(r, []));
const wrap = el(
'div',
{ class: 'evo' },
...rows.map((path) => {
const row = el('div', { class: 'evo__row' });
path.forEach((stage, i) => {
if (i > 0) {
row.append(
el(
'span',
{ class: 'evo__arrow' },
'→',
stage.via ? el('span', { class: 'evo__via' }, stage.via) : null,
),
);
}
row.append(node(stage, style, versionGroup));
});
return row;
}),
);
if (pruned) {
wrap.append(
el('p', { class: 'evo__note' }, 'Cross-generation stages hidden for this game.'),
);
}
return wrap;
}

View File

@ -0,0 +1,66 @@
import { el } from '../lib/dom.js';
const prettify = (s) => s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
/**
* Wild encounter locations for the selected game, from
* /pokemon/{id}/encounters. PokéAPI has no map imagery or coordinates, so
* this is a grouped text list: area, level range, method, best odds.
*/
export function Locations(encounters, versionNames) {
const wanted = new Set(versionNames);
const areas = [];
for (const row of encounters) {
const details = row.version_details.filter((v) => wanted.has(v.version.name));
if (!details.length) continue;
let min = Infinity;
let max = -Infinity;
let best = 0;
const methods = new Set();
const conditions = new Set();
for (const v of details) {
best = Math.max(best, v.max_chance || 0);
for (const d of v.encounter_details) {
min = Math.min(min, d.min_level);
max = Math.max(max, d.max_level);
if (d.method?.name) methods.add(prettify(d.method.name));
for (const c of d.condition_values || []) conditions.add(prettify(c.name));
}
}
areas.push({
name: prettify(row.location_area.name),
levels: min === max ? `Lv ${min}` : `Lv ${min}${max}`,
methods: [...methods].join(', '),
conditions: [...conditions].join(', '),
best,
});
}
if (!areas.length) {
return el(
'p',
{ class: 'detail__muted' },
'Not found in the wild in this game — it may be a gift, evolution, trade, fossil, or event Pokémon.',
);
}
areas.sort((a, b) => b.best - a.best);
return el(
'ul',
{ class: 'locations' },
...areas.map((a) =>
el(
'li',
{ class: 'locations__item' },
el('span', { class: 'locations__area' }, a.name),
el('span', { class: 'locations__meta' }, a.levels),
el('span', { class: 'locations__meta' }, a.methods),
a.conditions ? el('span', { class: 'locations__cond' }, a.conditions) : null,
a.best ? el('span', { class: 'locations__rate' }, `${a.best}%`) : null,
),
),
);
}

229
src/components/MovesList.js Normal file
View File

@ -0,0 +1,229 @@
import { el } from '../lib/dom.js';
import { getMove, mapLimit } from '../data/api.js';
import { TypeChip } from './TypeChip.js';
const METHOD_LABEL = {
'level-up': 'By level up',
machine: 'By TM / HM',
egg: 'Egg moves',
tutor: 'Move tutor',
'form-change': 'On form change',
};
const METHOD_ORDER = ['level-up', 'egg', 'machine', 'tutor', 'form-change'];
const DAMAGE_CLASS = { physical: 'Phys', special: 'Spec', status: 'Stat' };
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
function englishText(entries, key) {
const hit = entries.find((e) => e.language.name === 'en');
return hit ? hit[key].replace(/\s+/g, ' ') : '';
}
/**
* Some moves' power/accuracy/PP/type changed between generations. PokéAPI
* ships the current values plus `past_values`; pick the set that was live in
* the selected game's generation.
*/
// Before Gen 4, a move's physical/special class was decided by its TYPE,
// not set per move.
const PHYSICAL_TYPES = new Set([
'normal', 'fighting', 'poison', 'ground', 'flying', 'bug', 'rock', 'ghost', 'steel',
]);
function forGeneration(d, gen, genOfVg) {
const v = {
power: d.power,
accuracy: d.accuracy,
pp: d.pp,
type: d.type.name,
damage_class: d.damage_class ? d.damage_class.name : null,
effect_chance: d.effect_chance,
effect_entries: d.effect_entries,
};
const past = (d.past_values || [])
.map((p) => ({ ...p, _gen: genOfVg(p.version_group && p.version_group.name) }))
.sort((a, b) => a._gen - b._gen);
for (const p of past) {
if (p._gen >= gen) {
if (p.power != null) v.power = p.power;
if (p.accuracy != null) v.accuracy = p.accuracy;
if (p.pp != null) v.pp = p.pp;
if (p.effect_chance != null) v.effect_chance = p.effect_chance;
if (p.type) v.type = p.type.name;
if (p.effect_entries && p.effect_entries.length) v.effect_entries = p.effect_entries;
break;
}
}
if (gen < 4 && v.damage_class && v.damage_class !== 'status') {
v.damage_class = PHYSICAL_TYPES.has(v.type) ? 'physical' : 'special';
}
return v;
}
/**
* Learnset for the selected game. Rows wrap (no sideways scroll); each row
* expands on click to show the move's effect, and power/type/accuracy/PP
* load lazily when a group is first opened.
*/
export function MovesList(pokemonMoves, { versionGroupKey, gen = 9, genOfVg = () => 9 }) {
const byMethod = new Map();
for (const entry of pokemonMoves) {
const detail = entry.version_group_details.find(
(d) => d.version_group.name === versionGroupKey,
);
if (!detail) continue;
const method = detail.move_learn_method.name;
if (!byMethod.has(method)) byMethod.set(method, []);
byMethod.get(method).push({
id: idFromUrl(entry.move.url),
name: entry.move.name.replace(/-/g, ' '),
level: detail.level_learned_at,
data: null,
});
}
if (byMethod.size === 0) {
return el('p', { class: 'detail__muted' }, 'No move data for this game.');
}
const wrap = el('div', { class: 'moves' });
const methods = [...byMethod.keys()].sort(
(a, b) => METHOD_ORDER.indexOf(a) - METHOD_ORDER.indexOf(b),
);
for (const method of methods) {
const moves = byMethod.get(method);
moves.sort((a, b) =>
method === 'level-up'
? a.level - b.level || a.name.localeCompare(b.name)
: a.name.localeCompare(b.name),
);
const list = el('div', { class: 'moves__list' });
const enrichers = [];
for (const m of moves) {
const meta = el('span', { class: 'moverow__meta' }, el('span', { class: 'moverow__pending' }, '…'));
const body = el('div', { class: 'movebody', hidden: true });
let bodyFilled = false;
const row = el(
'button',
{
type: 'button',
class: 'moverow',
'aria-expanded': 'false',
onclick: async () => {
const open = body.hidden;
body.hidden = !open;
row.setAttribute('aria-expanded', String(open));
if (open && !bodyFilled) {
bodyFilled = true;
body.replaceChildren(el('span', { class: 'moverow__pending' }, 'Loading…'));
try {
m.data = m.data || (await getMove(m.id));
const v = forGeneration(m.data, gen, genOfVg);
fillMeta(meta, v);
fillBody(body, m.data, v);
} catch {
body.replaceChildren(el('span', { class: 'moverow__pending' }, 'Details unavailable.'));
}
}
},
},
method === 'level-up'
? el('span', { class: 'moverow__lv' }, m.level ? `Lv ${m.level}` : '—')
: null,
el('span', { class: 'moverow__name' }, m.name),
meta,
el('span', { class: 'moverow__chev', 'aria-hidden': 'true' }, '▾'),
);
list.append(el('div', { class: 'movecard' }, row, body));
enrichers.push(async () => {
try {
m.data = m.data || (await getMove(m.id));
fillMeta(meta, forGeneration(m.data, gen, genOfVg));
} catch {
meta.replaceChildren(el('span', { class: 'moverow__pending' }, '—'));
}
});
}
const group = el(
'details',
{ class: 'moves__group', open: method === 'level-up' },
el(
'summary',
{},
`${METHOD_LABEL[method] || method} `,
el('span', { class: 'moves__count' }, `(${moves.length})`),
),
list,
);
let enriched = false;
const runEnrich = async () => {
if (enriched) return;
enriched = true;
await mapLimit(enrichers, 6, (fn) => fn());
};
group.addEventListener('toggle', () => group.open && runEnrich());
if (method === 'level-up') runEnrich();
wrap.append(group);
}
return wrap;
}
function fillMeta(meta, v) {
meta.replaceChildren(
TypeChip(v.type),
el(
'span',
{ class: 'moverow__cat', dataset: { cat: v.damage_class || '' } },
DAMAGE_CLASS[v.damage_class] || '—',
),
stat('Pow', v.power ?? '—'),
stat('Acc', v.accuracy != null ? `${v.accuracy}%` : '—'),
stat('PP', v.pp ?? '—'),
);
}
function fillBody(body, d, v) {
const chance = v.effect_chance;
let effect =
englishText(v.effect_entries, 'short_effect') ||
englishText(d.flavor_text_entries, 'flavor_text') ||
'No description.';
if (chance != null) effect = effect.replace(/\$effect_chance%?/g, `${chance}%`);
const bits = [];
if (d.priority) bits.push(`Priority ${d.priority > 0 ? '+' : ''}${d.priority}`);
if (d.target?.name) bits.push(`Target: ${d.target.name.replace(/-/g, ' ')}`);
if (d.meta?.ailment?.name && d.meta.ailment.name !== 'none') {
bits.push(`May ${d.meta.ailment.name.replace(/-/g, ' ')}${d.meta.ailment_chance ? ` (${d.meta.ailment_chance}%)` : ''}`);
}
if (d.meta?.drain) bits.push(`${d.meta.drain > 0 ? 'Drains' : 'Recoil'} ${Math.abs(d.meta.drain)}%`);
if (d.meta?.healing) bits.push(`Heals ${d.meta.healing}%`);
if (d.meta?.crit_rate) bits.push(`+${d.meta.crit_rate} crit rate`);
if (d.meta?.flinch_chance) bits.push(`Flinch ${d.meta.flinch_chance}%`);
body.replaceChildren(
el('p', { class: 'movebody__effect' }, effect),
bits.length ? el('p', { class: 'movebody__bits' }, bits.join(' · ')) : null,
);
}
function stat(label, value) {
return el(
'span',
{ class: 'moverow__stat' },
el('span', { class: 'moverow__stat-k' }, label),
' ',
String(value),
);
}

View File

@ -1,20 +1,38 @@
import { el } from '../lib/dom.js';
import { gameSpriteUrl } from '../data/game-sprites.js';
const SPRITES =
'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon';
export function spriteUrl(id, style = 'default', shiny = false) {
const s = shiny ? '/shiny' : '';
function pixel(id, shiny) {
return `${SPRITES}${shiny ? '/shiny' : ''}/${id}.png`;
}
/**
* Resolve a sprite URL for a style. `game` uses era-accurate pixel art for
* the selected version group, falling back to HOME art for games that never
* had pixel sprites.
*/
export function spriteUrl(id, style = 'default', shiny = false, versionGroup) {
if (style === 'official') {
return `${SPRITES}/other/official-artwork${shiny ? '/shiny' : ''}/${id}.png`;
}
if (style === 'home') {
return `${SPRITES}/other/home${shiny ? '/shiny' : ''}/${id}.png`;
}
return `${SPRITES}${s}/${id}.png`;
if (style === 'game') {
return (
gameSpriteUrl(id, versionGroup, { shiny }) ||
`${SPRITES}/other/home${shiny ? '/shiny' : ''}/${id}.png`
);
}
return pixel(id, shiny);
}
export function Sprite(id, { style = 'default', shiny = false, alt = '', size = 96 } = {}) {
export function Sprite(
id,
{ style = 'default', shiny = false, versionGroup, alt = '', size = 96 } = {},
) {
const img = el('img', {
class: 'sprite',
loading: 'lazy',
@ -22,13 +40,13 @@ export function Sprite(id, { style = 'default', shiny = false, alt = '', size =
width: size,
height: size,
alt,
src: spriteUrl(id, style, shiny),
src: spriteUrl(id, style, shiny, versionGroup),
});
// Not every form has official/home art — fall back to the pixel sprite.
// Not every form/style has art — degrade to the plain pixel sprite once.
img.addEventListener(
'error',
() => {
img.src = spriteUrl(id, 'default', false);
img.src = pixel(id, false);
},
{ once: true },
);

View File

@ -0,0 +1,35 @@
import { el } from '../lib/dom.js';
import { TypeChip } from './TypeChip.js';
import { defensiveMatchups } from '../data/type-chart.js';
const GROUPS = [
{ key: '4', label: '4× damage', cls: 'x4' },
{ key: '2', label: '2× damage', cls: 'x2' },
{ key: '0.5', label: '½× damage', cls: 'half' },
{ key: '0.25', label: '¼× damage', cls: 'quarter' },
{ key: '0', label: 'No effect', cls: 'zero' },
];
/** Defensive type effectiveness for the given Pokémon types. */
export function TypeMatchups(types, gen = 9) {
const buckets = defensiveMatchups(types, gen);
const wrap = el('div', { class: 'matchups' });
for (const g of GROUPS) {
const list = buckets[g.key];
if (!list.length) continue;
wrap.append(
el(
'div',
{ class: `matchups__row matchups__row--${g.cls}` },
el('span', { class: 'matchups__label' }, g.label),
el('span', { class: 'matchups__types' }, ...list.map(TypeChip)),
),
);
}
if (!wrap.children.length) {
return el('p', { class: 'detail__muted' }, 'Takes normal damage from every type.');
}
return wrap;
}

View File

@ -24,3 +24,20 @@ export function getJSON(path) {
export const getPokemon = (idOrName) => getJSON(`pokemon/${idOrName}`);
export const getSpecies = (idOrName) => getJSON(`pokemon-species/${idOrName}`);
export const getEvolutionChain = (id) => getJSON(`evolution-chain/${id}`);
export const getMove = (idOrName) => getJSON(`move/${idOrName}`);
export const getEncounters = (id) => getJSON(`pokemon/${id}/encounters`);
/** Run async `fn` over `items` with a bounded number of parallel workers. */
export async function mapLimit(items, limit, fn) {
const out = new Array(items.length);
let i = 0;
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, async () => {
while (i < items.length) {
const idx = i++;
out[idx] = await fn(items[idx], idx);
}
}),
);
return out;
}

42
src/data/game-sprites.js Normal file
View File

@ -0,0 +1,42 @@
/**
* Era-accurate sprites: the PokeAPI sprites repo keeps per-generation pixel
* art under `versions/<gen>/<folder>/`. This maps a selected game (version
* group) to the closest sprite folder. Games newer than Gen 7 shipped no
* pixel sprites those return null and the caller falls back to HOME art.
*/
const REPO =
'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions';
const PATH_BY_VERSION_GROUP = {
'red-blue': 'generation-i/red-blue',
'red-green-japan': 'generation-i/red-blue',
'blue-japan': 'generation-i/red-blue',
yellow: 'generation-i/yellow',
'gold-silver': 'generation-ii/gold',
crystal: 'generation-ii/crystal',
'ruby-sapphire': 'generation-iii/ruby-sapphire',
emerald: 'generation-iii/emerald',
'firered-leafgreen': 'generation-iii/firered-leafgreen',
'diamond-pearl': 'generation-iv/diamond-pearl',
platinum: 'generation-iv/platinum',
'heartgold-soulsilver': 'generation-iv/heartgold-soulsilver',
'black-white': 'generation-v/black-white',
'black-2-white-2': 'generation-v/black-white',
'x-y': 'generation-vi/x-y',
'omega-ruby-alpha-sapphire': 'generation-vi/omegaruby-alphasapphire',
'sun-moon': 'generation-vii/ultra-sun-ultra-moon',
'ultra-sun-ultra-moon': 'generation-vii/ultra-sun-ultra-moon',
'brilliant-diamond-and-shining-pearl':
'generation-viii/brilliant-diamond-shining-pearl',
};
/** URL for a game-era pixel sprite, or null if that game has none. */
export function gameSpriteUrl(id, versionGroupKey, { shiny = false } = {}) {
const path = PATH_BY_VERSION_GROUP[versionGroupKey];
if (!path) return null;
return `${REPO}/${path}/${shiny ? 'shiny/' : ''}${id}.png`;
}
export function hasGameSprites(versionGroupKey) {
return Boolean(PATH_BY_VERSION_GROUP[versionGroupKey]);
}

81
src/data/type-chart.js Normal file
View File

@ -0,0 +1,81 @@
/**
* Static type effectiveness chart (Gen 6+, 18 types). Baked in so matchups
* work offline with zero fetches.
*
* CHART[attacking][defending] holds only the non-1x multipliers.
*/
export const TYPES = [
'normal', 'fire', 'water', 'electric', 'grass', 'ice', 'fighting', 'poison',
'ground', 'flying', 'psychic', 'bug', 'rock', 'ghost', 'dragon', 'dark',
'steel', 'fairy',
];
const CHART = {
normal: { rock: 0.5, ghost: 0, steel: 0.5 },
fire: { fire: 0.5, water: 0.5, grass: 2, ice: 2, bug: 2, rock: 0.5, dragon: 0.5, steel: 2 },
water: { fire: 2, water: 0.5, grass: 0.5, ground: 2, rock: 2, dragon: 0.5 },
electric: { water: 2, electric: 0.5, grass: 0.5, ground: 0, flying: 2, dragon: 0.5 },
grass: { fire: 0.5, water: 2, grass: 0.5, poison: 0.5, ground: 2, flying: 0.5, bug: 0.5, rock: 2, dragon: 0.5, steel: 0.5 },
ice: { fire: 0.5, water: 0.5, grass: 2, ice: 0.5, ground: 2, flying: 2, dragon: 2, steel: 0.5 },
fighting: { normal: 2, ice: 2, poison: 0.5, flying: 0.5, psychic: 0.5, bug: 0.5, rock: 2, ghost: 0, dark: 2, steel: 2, fairy: 0.5 },
poison: { grass: 2, poison: 0.5, ground: 0.5, rock: 0.5, ghost: 0.5, steel: 0, fairy: 2 },
ground: { fire: 2, electric: 2, grass: 0.5, poison: 2, flying: 0, bug: 0.5, rock: 2, steel: 2 },
flying: { electric: 0.5, grass: 2, fighting: 2, bug: 2, rock: 0.5, steel: 0.5 },
psychic: { fighting: 2, poison: 2, psychic: 0.5, dark: 0, steel: 0.5 },
bug: { fire: 0.5, grass: 2, fighting: 0.5, poison: 0.5, flying: 0.5, psychic: 2, ghost: 0.5, dark: 2, steel: 0.5, fairy: 0.5 },
rock: { fire: 2, ice: 2, fighting: 0.5, ground: 0.5, flying: 2, bug: 2, steel: 0.5 },
ghost: { normal: 0, psychic: 2, ghost: 2, dark: 0.5 },
dragon: { dragon: 2, steel: 0.5, fairy: 0 },
dark: { fighting: 0.5, psychic: 2, ghost: 2, dark: 0.5, fairy: 0.5 },
steel: { fire: 0.5, water: 0.5, electric: 0.5, ice: 2, rock: 2, steel: 0.5, fairy: 2 },
fairy: { fire: 0.5, fighting: 2, poison: 0.5, dragon: 2, dark: 2, steel: 0.5 },
};
// Per-era overrides, applied on top of the modern (Gen 6+) chart.
// attacking -> { defending: multiplier }
const PRE_GEN6 = {
ghost: { steel: 0.5 }, // Steel resisted Ghost/Dark before Gen 6
dark: { steel: 0.5 },
};
const GEN1_ONLY = {
bug: { poison: 2 }, // Bug and Poison were mutually super-effective
poison: { bug: 2 },
ghost: { psychic: 0 }, // famous Gen 1 bug: Ghost did nothing to Psychic
};
function pairMultiplier(attacking, def, gen) {
if (gen <= 1 && GEN1_ONLY[attacking] && def in GEN1_ONLY[attacking]) {
return GEN1_ONLY[attacking][def];
}
if (gen < 6 && PRE_GEN6[attacking] && def in PRE_GEN6[attacking]) {
return PRE_GEN6[attacking][def];
}
const row = CHART[attacking] || {};
return def in row ? row[def] : 1;
}
/** Multiplier of `attacking` type against a defender of `defTypes`. */
export function multiplier(attacking, defTypes, gen = 9) {
let m = 1;
for (const def of defTypes) {
if (gen < 6 && def === 'fairy') continue; // Fairy did not exist
m *= pairMultiplier(attacking, def, gen);
}
return m;
}
/**
* Defensive matchups for a Pokémon, bucketed by multiplier.
* Returns { '4': [...types], '2': [...], '0.5': [...], '0.25': [...], '0': [...] }
*/
export function defensiveMatchups(defTypes, gen = 9) {
const buckets = { '4': [], '2': [], '0.5': [], '0.25': [], '0': [] };
const atkTypes = gen < 6 ? TYPES.filter((t) => t !== 'fairy') : TYPES;
for (const atk of atkTypes) {
const m = multiplier(atk, defTypes, gen);
if (m === 1) continue;
const key = String(m);
if (buckets[key]) buckets[key].push(atk);
}
return buckets;
}

View File

@ -4,8 +4,11 @@
}
.view-host {
/* Explicit width (not just max-width) so that as a grid item on desktop it
fills the column instead of shrink-wrapping to its content. */
width: 100%;
max-width: var(--maxw);
margin: 0 auto;
margin-inline: auto;
padding: var(--gap);
padding-bottom: calc(var(--nav-size) + env(safe-area-inset-bottom) + var(--gap));
}
@ -256,6 +259,11 @@
grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr));
}
}
@media (min-width: 1200px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
}
}
.grid__empty {
grid-column: 1 / -1;
text-align: center;
@ -401,17 +409,25 @@
/* ---------- Detail ---------------------------------------------- */
.detail {
max-width: 640px;
max-width: 1100px;
margin: 0 auto;
}
.detail__nav {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: baseline;
gap: 8px 12px;
flex-wrap: wrap;
margin-bottom: 8px;
font-size: 0.9rem;
text-transform: capitalize;
}
.detail__nav a {
max-width: 45vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail__num {
margin: 0;
color: var(--text-dim);
@ -427,16 +443,45 @@
display: flex;
gap: 6px;
}
.detail__panels {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
align-items: start;
margin-top: 16px;
}
.detail__section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
margin: 0;
min-width: 0;
}
.detail__section h2 {
font-size: 0.95rem;
margin: 0 0 12px;
}
.detail__section--wide {
grid-column: 1 / -1;
}
.detail__section--media {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.detail__media {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 16px 0;
}
.detail__media .sprite {
width: min(240px, 70vw);
height: min(240px, 70vw);
width: min(240px, 60vw);
height: min(240px, 60vw);
object-fit: contain;
}
.detail__track {
@ -444,7 +489,10 @@
gap: 8px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 16px;
}
.detail__muted {
color: var(--text-dim);
margin: 0;
}
.toggle {
appearance: none;
@ -469,12 +517,8 @@
padding: 14px 16px;
font-style: italic;
color: var(--text-dim);
}
.detail__stats {
margin-top: 20px;
}
.detail__stats h2 {
font-size: 1rem;
max-width: 60ch;
overflow-wrap: anywhere;
}
.statbar {
display: grid;
@ -510,9 +554,9 @@
}
.detail__facts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 16px;
margin-top: 20px;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px 16px;
margin-top: 22px;
}
.fact dt {
font-size: 0.72rem;
@ -522,8 +566,231 @@
}
.fact dd {
margin: 2px 0 0;
}
/* ---------- Evolution chain ----------------------------------- */
.evo {
display: flex;
flex-direction: column;
gap: 14px;
}
.evo__row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.evo__node {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
text-decoration: none;
color: inherit;
padding: 6px 8px;
border-radius: var(--radius-sm);
}
.evo__node:hover {
background: var(--surface-2);
}
.evo__node .sprite {
width: 72px;
height: 72px;
object-fit: contain;
image-rendering: pixelated;
}
.evo__name {
font-size: 0.8rem;
font-weight: 600;
text-transform: capitalize;
}
.evo__arrow {
display: flex;
flex-direction: column;
align-items: center;
color: var(--text-dim);
font-size: 0.95rem;
}
.evo__via {
font-size: 0.66rem;
text-transform: capitalize;
white-space: nowrap;
}
/* ---------- Type matchups ---------------------------------- */
.matchups {
display: flex;
flex-direction: column;
gap: 8px;
}
.matchups__row {
display: grid;
grid-template-columns: 84px 1fr;
align-items: center;
gap: 12px;
}
.matchups__label {
font-size: 0.78rem;
font-weight: 700;
color: var(--text-dim);
}
.matchups__row--x4 .matchups__label,
.matchups__row--x2 .matchups__label {
color: var(--danger);
}
.matchups__row--half .matchups__label,
.matchups__row--quarter .matchups__label {
color: var(--good);
}
.matchups__types {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
/* ---------- Moves ------------------------------------------- */
.moves {
display: flex;
flex-direction: column;
gap: 8px;
}
.moves__group {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 14px;
background: var(--bg);
}
.moves__group summary {
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
}
.moves__count {
color: var(--text-dim);
font-weight: 400;
}
.moves__list {
margin-top: 8px;
}
.movecard {
border-top: 1px solid var(--border);
}
.moverow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
width: 100%;
padding: 8px 2px;
background: none;
border: none;
color: inherit;
font: inherit;
font-size: 0.82rem;
text-align: left;
cursor: pointer;
}
.moverow:hover {
color: var(--text);
}
.moverow__chev {
color: var(--text-dim);
transition: transform 0.15s ease;
}
.moverow[aria-expanded="true"] .moverow__chev {
transform: rotate(180deg);
}
.movebody {
padding: 0 2px 12px;
font-size: 0.82rem;
}
.movebody__effect {
margin: 0 0 6px;
}
.movebody__bits {
margin: 0;
color: var(--text-dim);
font-size: 0.76rem;
}
.moverow__lv {
flex: 0 0 3.2rem;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.moverow__name {
flex: 1 1 7rem;
min-width: 0;
font-weight: 600;
text-transform: capitalize;
}
.moverow__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
color: var(--text-dim);
}
.moverow__pending {
font-style: italic;
}
.moverow__cat {
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.moverow__cat[data-cat="physical"] { color: #e0762f; }
.moverow__cat[data-cat="special"] { color: #4b8fd6; }
.moverow__cat[data-cat="status"] { color: #8a8f98; }
.moverow__stat {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.moverow__stat-k {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.03em;
opacity: 0.7;
}
/* ---------- Locations ------------------------------------- */
.locations {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 6px;
}
.locations__item {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 12px;
padding: 7px 0;
border-top: 1px solid var(--border);
font-size: 0.85rem;
}
.locations__item:first-child {
border-top: none;
}
.locations__area {
font-weight: 600;
flex: 1 1 10rem;
}
.locations__meta {
color: var(--text-dim);
font-size: 0.8rem;
}
.locations__cond {
color: var(--text-dim);
font-size: 0.72rem;
font-style: italic;
}
.locations__rate {
font-variant-numeric: tabular-nums;
color: var(--good);
font-size: 0.8rem;
}
/* ---------- Search --------------------------------------------- */
.search__results {

View File

@ -16,7 +16,7 @@
--radius-sm: 9px;
--gap: 16px;
--nav-size: 64px;
--maxw: 1200px;
--maxw: 1440px;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 6px 20px rgba(0, 0, 0, 0.06);
--font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
@ -76,6 +76,11 @@ html,
body {
margin: 0;
padding: 0;
overflow-x: clip; /* contain accidental overflow without breaking sticky */
}
img {
max-width: 100%;
}
body {

View File

@ -24,7 +24,7 @@ export async function DexGrid() {
const search = el('input', {
class: 'dexgrid__search',
type: 'search',
placeholder: 'Filter by name or number…',
placeholder: 'Filter this Pokédex…',
oninput: (e) => {
query = e.target.value.trim().toLowerCase();
paintGrid();
@ -85,7 +85,12 @@ export async function DexGrid() {
favorite: false,
};
if (!filterTest(e)) continue;
frag.append(Card(species, number, { spriteStyle: st.spriteStyle }));
frag.append(
Card(species, number, {
spriteStyle: st.spriteStyle,
versionGroup: st.versionGroup,
}),
);
shown++;
}

View File

@ -1,17 +1,34 @@
import { el, clear, onTeardown } from '../lib/dom.js';
import { loadSnapshot } from '../data/snapshot.js';
import { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js';
import { getPokemon, getSpecies } from '../data/api.js';
import { getPokemon, getSpecies, getEvolutionChain, getEncounters } from '../data/api.js';
import { settings } from '../store/settings.js';
import { entry, toggle } from '../store/selection.js';
import { Sprite } from '../components/Sprite.js';
import { TypeChip } from '../components/TypeChip.js';
import { StatBar } from '../components/StatBar.js';
import { EvolutionChain } from '../components/EvolutionChain.js';
import { MovesList } from '../components/MovesList.js';
import { TypeMatchups } from '../components/TypeMatchups.js';
import { Locations } from '../components/Locations.js';
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
const STAT_NAMES = [
'hp',
'attack',
'defense',
'special-attack',
'special-defense',
'speed',
];
export async function PokemonDetail(nationalId) {
const view = el('section', { class: 'view detail' });
const snap = await loadSnapshot();
const st = settings.get();
const vg = snap.versionGroupByKey.get(st.versionGroup);
const vgGen = vg ? vg.generation : 9;
// Prev / next within the currently selected dex.
const dex = resolvePokedex(snap, st);
@ -21,9 +38,7 @@ export async function PokemonDetail(nationalId) {
const next = pos >= 0 && pos < rows.length - 1 ? rows[pos + 1] : null;
const regionalNumber = pos >= 0 ? rows[pos].number : null;
view.append(
el('div', { class: 'detail__skeleton' }, 'Loading Pokémon…'),
);
view.append(el('div', { class: 'view--loading' }, 'Loading Pokémon…'));
let pokemon;
let species;
@ -34,7 +49,9 @@ export async function PokemonDetail(nationalId) {
]);
} catch (err) {
clear(view).append(
el('div', { class: 'view--error' },
el(
'div',
{ class: 'view--error' },
el('h1', {}, 'Could not load this Pokémon'),
el('p', {}, navigator.onLine ? err.message : 'You appear to be offline.'),
el('a', { class: 'button', href: '#/' }, 'Back to the dex'),
@ -43,38 +60,37 @@ export async function PokemonDetail(nationalId) {
return view;
}
// ---- Artwork + shiny toggle -----------------------------------------
const artStyle = st.spriteStyle === 'default' ? 'official' : st.spriteStyle;
let shiny = st.showShiny;
const media = el('div', { class: 'detail__media' });
const artwork = Sprite(nationalId, {
style: st.spriteStyle === 'default' ? 'official' : st.spriteStyle,
shiny,
alt: species.name,
size: 240,
});
function paintArt() {
media.replaceChildren(
Sprite(nationalId, {
style: artStyle,
shiny,
versionGroup: st.versionGroup,
alt: species.name,
size: 240,
}),
el(
'button',
{
class: 'button button--ghost',
type: 'button',
onclick: () => {
shiny = !shiny;
paintArt();
},
},
shiny ? 'Show normal' : 'Show shiny',
),
);
}
paintArt();
const shinyToggle = el(
'button',
{
class: 'button button--ghost',
type: 'button',
onclick: () => {
shiny = !shiny;
const fresh = Sprite(nationalId, {
style: st.spriteStyle === 'default' ? 'official' : st.spriteStyle,
shiny,
alt: species.name,
size: 240,
});
artwork.replaceWith(fresh);
artworkRef.node = fresh;
shinyToggle.textContent = shiny ? 'Show normal' : 'Show shiny';
},
},
shiny ? 'Show normal' : 'Show shiny',
);
const artworkRef = { node: artwork };
// Caught / seen / favorite controls — write to the unified selection.
// ---- Seen / caught / favorite (writes the unified selection) --------
const trackBar = el('div', { class: 'detail__track' });
function renderTrack() {
const e = entry(nationalId);
@ -101,28 +117,81 @@ export async function PokemonDetail(nationalId) {
}
renderTrack();
// Flavor text for the currently selected game, newest fallback otherwise.
const vg = snap.versionGroupByKey.get(st.versionGroup);
// ---- Flavor text for the selected game -----------------------------
const wantedVersions = new Set(vg ? vg.versions : []);
const englishEntries = species.flavor_text_entries.filter(
const englishFlavor = species.flavor_text_entries.filter(
(f) => f.language.name === 'en',
);
const flavor =
englishEntries.find((f) => wantedVersions.has(f.version.name)) ||
englishEntries[englishEntries.length - 1];
englishFlavor.find((f) => wantedVersions.has(f.version.name)) ||
englishFlavor[englishFlavor.length - 1];
// ---- Stats --------------------------------------------------------
const stat = (name) =>
pokemon.stats.find((s) => s.stat.name === name)?.base_stat ?? 0;
const statNames = [
'hp',
'attack',
'defense',
'special-attack',
'special-defense',
'speed',
];
const total = statNames.reduce((sum, n) => sum + stat(n), 0);
const statTotal = STAT_NAMES.reduce((sum, n) => sum + stat(n), 0);
// ---- Abilities: Gen 3+ only; hidden abilities Gen 5+ -------------
const abilities = pokemon.abilities
.filter((a) => !a.is_hidden || vgGen >= 5)
.map((a) => prettify(a.ability.name) + (a.is_hidden ? ' (hidden)' : ''));
// ---- Evolution section (filled in async) -------------------------
const evoSection = el(
'section',
{ class: 'detail__section' },
el('h2', {}, 'Evolution'),
el('p', { class: 'detail__muted' }, 'Loading…'),
);
loadEvolution(species, st, snap, vgGen).then((node) => {
evoSection.replaceChildren(el('h2', {}, 'Evolution'), node);
});
const movesSection = el(
'section',
{ class: 'detail__section detail__section--wide' },
el('h2', {}, `Moves — ${prettify(st.versionGroup)}`),
MovesList(pokemon.moves, {
versionGroupKey: st.versionGroup,
gen: vgGen,
genOfVg: (name) => snap.versionGroupByKey.get(name)?.generation ?? 9,
}),
);
// PokéAPI has no wild-encounter data for these games yet.
const NO_ENCOUNTER_DATA = new Set([
'scarlet-violet',
'the-teal-mask',
'the-indigo-disk',
'brilliant-diamond-and-shining-pearl',
'legends-arceus',
]);
const locHeading = () => el('h2', {}, `Where to find — ${prettify(st.versionGroup)}`);
const locSection = el(
'section',
{ class: 'detail__section detail__section--wide' },
locHeading(),
el('p', { class: 'detail__muted' }, 'Loading…'),
);
if (NO_ENCOUNTER_DATA.has(st.versionGroup)) {
locSection.replaceChildren(
locHeading(),
el('p', { class: 'detail__muted' }, "PokéAPI doesn't have wild-encounter data for this game yet."),
);
} else {
getEncounters(nationalId)
.then((data) => {
locSection.replaceChildren(locHeading(), Locations(data, vg ? vg.versions : []));
})
.catch(() => {
locSection.replaceChildren(
locHeading(),
el('p', { class: 'detail__muted' }, 'Location data unavailable offline.'),
);
});
}
// ---- Assemble ---------------------------------------------------
clear(view).append(
el(
'nav',
@ -138,41 +207,65 @@ export async function PokemonDetail(nationalId) {
el(
'header',
{ class: 'detail__header' },
el('p', { class: 'detail__num' },
el(
'p',
{ class: 'detail__num' },
regionalNumber != null
? `#${String(regionalNumber).padStart(3, '0')} · ${prettify(dex.name || dex.key)}`
: `#${String(nationalId).padStart(4, '0')} · National`),
: `#${String(nationalId).padStart(4, '0')} · National`,
),
el('h1', { class: 'detail__name' }, species.name.replace(/-/g, ' ')),
el('div', { class: 'detail__types' }, ...pokemon.types.map((t) => TypeChip(t.type.name))),
el(
'div',
{ class: 'detail__types' },
...pokemon.types.map((t) => TypeChip(t.type.name)),
),
),
el(
'div',
{ class: 'detail__media' },
artwork,
shinyToggle,
),
trackBar,
flavor
? el('p', { class: 'detail__flavor' }, flavor.flavor_text.replace(/\s+/g, ' '))
: null,
el(
'section',
{ class: 'detail__stats' },
el('h2', {}, 'Base stats'),
...statNames.map((n) => StatBar(n, stat(n))),
el('div', { class: 'statbar statbar--total' },
el('span', { class: 'statbar__label' }, 'Total'),
el('span', { class: 'statbar__value' }, String(total)),
el('div', { class: 'statbar__track' }),
'div',
{ class: 'detail__panels' },
el('section', { class: 'detail__section detail__section--media' }, media, trackBar),
el(
'section',
{ class: 'detail__section' },
el('h2', {}, 'Base stats'),
...STAT_NAMES.map((n) => StatBar(n, stat(n))),
el(
'div',
{ class: 'statbar statbar--total' },
el('span', { class: 'statbar__label' }, 'Total'),
el('span', { class: 'statbar__value' }, String(statTotal)),
el('div', { class: 'statbar__track' }),
),
),
),
el(
'dl',
{ class: 'detail__facts' },
fact('Height', `${pokemon.height / 10} m`),
fact('Weight', `${pokemon.weight / 10} kg`),
fact('Abilities', pokemon.abilities.map((a) => prettify(a.ability.name) + (a.is_hidden ? ' (hidden)' : '')).join(', ')),
fact('Generation', prettify(species.generation.name)),
el(
'section',
{ class: 'detail__section' },
el('h2', {}, 'Details'),
el(
'dl',
{ class: 'detail__facts' },
fact('Height', `${(pokemon.height / 10).toFixed(1)} m`),
fact('Weight', `${(pokemon.weight / 10).toFixed(1)} kg`),
abilities.length && vgGen >= 3 ? fact('Abilities', abilities.join(', ')) : null,
fact('Introduced', prettify(species.generation.name)),
),
),
el(
'section',
{ class: 'detail__section' },
el('h2', {}, 'Type matchups (defending)'),
TypeMatchups(
pokemon.types.map((t) => t.type.name),
vgGen,
),
),
evoSection,
locSection,
movesSection,
),
);
@ -181,9 +274,23 @@ export async function PokemonDetail(nationalId) {
return view;
}
function fact(label, value) {
return el('div', { class: 'fact' },
el('dt', {}, label),
el('dd', {}, value),
);
async function loadEvolution(species, st, snap, vgGen) {
if (!species.evolution_chain?.url) {
return el('p', { class: 'detail__muted' }, 'Does not evolve.');
}
try {
const chain = await getEvolutionChain(idFromUrl(species.evolution_chain.url));
return EvolutionChain(chain, {
style: st.spriteStyle === 'default' ? 'default' : st.spriteStyle,
versionGroup: st.versionGroup,
maxGen: vgGen,
genOf: (id) => snap.speciesById.get(id)?.generation ?? 1,
});
} catch {
return el('p', { class: 'detail__muted' }, 'Evolution data unavailable offline.');
}
}
function fact(label, value) {
return el('div', { class: 'fact' }, el('dt', {}, label), el('dd', {}, value));
}

View File

@ -23,7 +23,12 @@ export async function SearchView() {
});
view.append(
el('header', { class: 'view__header' }, el('h1', {}, 'Search')),
el(
'header',
{ class: 'view__header' },
el('h1', {}, 'Search all Pokémon'),
el('p', {}, 'Look up any Pokémon from any game — not limited to your selected Pokédex.'),
),
input,
results,
);

View File

@ -16,7 +16,8 @@ export async function SettingsView() {
});
const spriteField = selectField('Sprite style', st.spriteStyle, [
['default', 'Pixel'],
['default', 'Pixel (modern)'],
['game', 'Game era (pixel art from the selected game)'],
['official', 'Official artwork'],
['home', 'Pokémon HOME'],
], (v) => settings.set({ spriteStyle: v }));