Compare commits
7 Commits
8ebdb9d197
...
f9f2b8f1e8
| Author | SHA1 | Date | |
|---|---|---|---|
| f9f2b8f1e8 | |||
| 40667cbdb6 | |||
| 415cf3edec | |||
| 79a5efaf11 | |||
| ddc84c2a48 | |||
| 5717b0a38a | |||
| 2e4572428a |
14
README.md
14
README.md
@ -71,6 +71,10 @@ Working app with a type-themed UI:
|
||||
|
||||
- **Dex grid** — type-tinted cards (official artwork, spotlight, ghost number),
|
||||
per-dex progress, name/number filter, caught/favorite filters.
|
||||
- **Forms** — Megas, Gigantamax, regional forms and alternate formes (264
|
||||
across 203 species) in the snapshot; a form switcher on the detail page
|
||||
rebuilds types / stats / matchups / abilities / learnset / artwork for
|
||||
the chosen form. Cards show a "+N forms" badge.
|
||||
- **Detail** — type-gradient hero + tabbed sheet (About / Stats / Evolution /
|
||||
Moves / Locations); coloured animated stat bars; defensive type matchups;
|
||||
evolution chain re-parented to the selected game's generation; learnset with
|
||||
@ -80,9 +84,13 @@ Working app with a type-themed UI:
|
||||
applies Gen 1 / pre-Gen 6 rules.
|
||||
- **Games** — overlay picker with stylised version-colour cover tiles,
|
||||
sub-dex switch, and an "All games" (National, no gen limits) option.
|
||||
- **Search** — tabbed lookup: Pokémon (offline, from the snapshot), Moves
|
||||
and Items (lazy name index + on-demand detail, SW-cached), each with its
|
||||
own detail page. Query, scroll and tab persist.
|
||||
- **Search** — tabbed lookup: Pokémon, Moves and Items, all browsable
|
||||
offline from the snapshot. Moves filter by type / damage class / "TMs in
|
||||
this game" and sort by power / accuracy / recency; Items filter by
|
||||
category. Each has its own detail page. Query, scroll, tab and filters
|
||||
persist.
|
||||
- **Team** — a lineup of up to 6 with a Coverage table (weaknesses, STAB
|
||||
gaps) and a Compare table; toggleable in the nav from Settings.
|
||||
- **Settings** — theme (System / Light / Dark / Black / Sepia) + accent
|
||||
colour, sprite style, JSON export/import.
|
||||
- **PWA** — Workbox SW: precache shell + snapshot, SWR for API JSON,
|
||||
|
||||
@ -30,6 +30,34 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
|
||||
const pretty = (k) => k.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
const FORM_LABELS = {
|
||||
mega: 'Mega',
|
||||
'mega-x': 'Mega X',
|
||||
'mega-y': 'Mega Y',
|
||||
gmax: 'Gigantamax',
|
||||
alola: 'Alolan',
|
||||
galar: 'Galarian',
|
||||
hisui: 'Hisuian',
|
||||
paldea: 'Paldean',
|
||||
primal: 'Primal',
|
||||
origin: 'Origin',
|
||||
altered: 'Altered',
|
||||
incarnate: 'Incarnate',
|
||||
therian: 'Therian',
|
||||
};
|
||||
function formLabel(slug, speciesName) {
|
||||
const rest = slug.startsWith(`${speciesName}-`) ? slug.slice(speciesName.length + 1) : slug;
|
||||
return FORM_LABELS[rest] || pretty(rest);
|
||||
}
|
||||
function formCategory(slug, speciesName) {
|
||||
const rest = slug.startsWith(`${speciesName}-`) ? slug.slice(speciesName.length + 1) : slug;
|
||||
if (rest.startsWith('mega')) return 'mega';
|
||||
if (rest === 'gmax') return 'gmax';
|
||||
if (['alola', 'galar', 'hisui', 'paldea'].includes(rest)) return 'regional';
|
||||
if (rest === 'primal') return 'primal';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
const _cache = new Map();
|
||||
async function api(path) {
|
||||
if (_cache.has(path)) return _cache.get(path);
|
||||
@ -152,14 +180,9 @@ async function main() {
|
||||
.map((r) => idFromUrl(r.url))
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
console.log(`Fetching ${ids.length} species (stats, typings, flags) …`);
|
||||
const species = await mapLimit(ids, CONCURRENCY, async (id) => {
|
||||
const [pk, sp] = await Promise.all([
|
||||
api(`pokemon/${id}`),
|
||||
api(`pokemon-species/${id}`),
|
||||
]);
|
||||
const statMap = (pk) => {
|
||||
const s = Object.fromEntries(pk.stats.map((x) => [x.stat.name, x.base_stat]));
|
||||
const stats = {
|
||||
return {
|
||||
hp: s.hp ?? 0,
|
||||
atk: s.attack ?? 0,
|
||||
def: s.defense ?? 0,
|
||||
@ -167,7 +190,61 @@ async function main() {
|
||||
spd: s['special-defense'] ?? 0,
|
||||
spe: s.speed ?? 0,
|
||||
};
|
||||
};
|
||||
const bstOf = (st) => st.hp + st.atk + st.def + st.spa + st.spd + st.spe;
|
||||
const abilKey = (pk) => pk.abilities.map((a) => a.ability.name).sort().join(',');
|
||||
const statKey = (st) => `${st.hp}/${st.atk}/${st.def}/${st.spa}/${st.spd}/${st.spe}`;
|
||||
|
||||
console.log(`Fetching ${ids.length} species (stats, typings, flags, forms) …`);
|
||||
const species = await mapLimit(ids, CONCURRENCY, async (id) => {
|
||||
const [pk, sp] = await Promise.all([
|
||||
api(`pokemon/${id}`),
|
||||
api(`pokemon-species/${id}`),
|
||||
]);
|
||||
const stats = statMap(pk);
|
||||
const baseTypes = pk.types.slice().sort((a, b) => a.slot - b.slot).map((t) => t.type.name);
|
||||
const baseAbil = abilKey(pk);
|
||||
const baseStatKey = statKey(stats);
|
||||
|
||||
// Non-default varieties = forms (megas, G-max, regional, alt formes).
|
||||
const variantSlugs = (sp.varieties || [])
|
||||
.filter((v) => !v.is_default)
|
||||
.map((v) => v.pokemon.name);
|
||||
const forms = [];
|
||||
for (const slug of variantSlugs) {
|
||||
let fp;
|
||||
try {
|
||||
fp = await api(`pokemon/${slug}`);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const fstats = statMap(fp);
|
||||
const ftypes = fp.types.slice().sort((a, b) => a.slot - b.slot).map((t) => t.type.name);
|
||||
const cat = formCategory(slug, sp.name);
|
||||
const entry = {
|
||||
slug,
|
||||
id: fp.id,
|
||||
name: formLabel(slug, sp.name),
|
||||
category: cat,
|
||||
types: ftypes,
|
||||
stats: fstats,
|
||||
bst: bstOf(fstats),
|
||||
height: fp.height,
|
||||
weight: fp.weight,
|
||||
};
|
||||
// Drop purely-cosmetic forms: same typing, stats AND abilities as the
|
||||
// base, and not a named category (mega/gmax/regional/primal).
|
||||
const cosmetic =
|
||||
cat === 'other' &&
|
||||
ftypes.join() === baseTypes.join() &&
|
||||
statKey(fstats) === baseStatKey &&
|
||||
abilKey(fp) === baseAbil;
|
||||
if (cosmetic) continue;
|
||||
forms.push(entry);
|
||||
}
|
||||
|
||||
return {
|
||||
forms,
|
||||
id,
|
||||
name: sp.name,
|
||||
generation: idFromUrl(sp.generation.url),
|
||||
@ -196,6 +273,66 @@ async function main() {
|
||||
};
|
||||
});
|
||||
|
||||
// ---- Moves index -------------------------------------------
|
||||
const moveIndex = await api('move?limit=100000');
|
||||
console.log(`Fetching ${moveIndex.results.length} moves …`);
|
||||
const moves = await mapLimit(moveIndex.results, CONCURRENCY, async (r) => {
|
||||
const m = await api(r.url);
|
||||
return {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
type: m.type?.name || 'normal',
|
||||
damageClass: m.damage_class?.name || null,
|
||||
power: m.power,
|
||||
accuracy: m.accuracy,
|
||||
pp: m.pp,
|
||||
priority: m.priority,
|
||||
generation: idFromUrl(m.generation.url),
|
||||
// version-group -> machine id (resolve to TM/HM number on demand)
|
||||
machines: (m.machines || []).map((x) => ({
|
||||
vg: x.version_group.name,
|
||||
id: idFromUrl(x.machine.url),
|
||||
})),
|
||||
};
|
||||
});
|
||||
moves.sort((a, b) => a.id - b.id);
|
||||
|
||||
// ---- Abilities index ------------------------------------------
|
||||
const abilityIndex = await api('ability?limit=100000');
|
||||
console.log(`Fetching ${abilityIndex.results.length} abilities …`);
|
||||
const abilities = await mapLimit(abilityIndex.results, CONCURRENCY, async (r) => {
|
||||
const a = await api(r.url);
|
||||
const en = (a.effect_entries || []).find((e) => e.language.name === 'en');
|
||||
const flavour = [...(a.flavor_text_entries || [])]
|
||||
.reverse()
|
||||
.find((e) => e.language.name === 'en');
|
||||
return {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
generation: idFromUrl(a.generation.url),
|
||||
isMainSeries: a.is_main_series,
|
||||
effect:
|
||||
(en && (en.short_effect || en.effect)) ||
|
||||
(flavour && flavour.flavor_text) ||
|
||||
'',
|
||||
pokemon: [
|
||||
...new Set(a.pokemon.map((x) => idFromUrl(x.pokemon.url)).filter((n) => n <= 100000)),
|
||||
],
|
||||
};
|
||||
});
|
||||
abilities.sort((a, b) => a.id - b.id);
|
||||
|
||||
// ---- Item categories (gives every item a category cheaply) ----
|
||||
const catIndex = await api('item-category?limit=100');
|
||||
const items = [];
|
||||
await mapLimit(catIndex.results, CONCURRENCY, async (c) => {
|
||||
const cat = await api(c.name.startsWith('http') ? c.name : `item-category/${c.name}`);
|
||||
for (const it of cat.items) {
|
||||
items.push({ id: idFromUrl(it.url), name: it.name, category: cat.name });
|
||||
}
|
||||
});
|
||||
items.sort((a, b) => a.id - b.id);
|
||||
|
||||
// ---- Write ----------------------------------------------------
|
||||
const snapshot = {
|
||||
meta: {
|
||||
@ -203,6 +340,9 @@ async function main() {
|
||||
source: BASE,
|
||||
counts: {
|
||||
species: species.length,
|
||||
moves: moves.length,
|
||||
items: items.length,
|
||||
abilities: abilities.length,
|
||||
pokedexes: pokedexes.length,
|
||||
versionGroups: versionGroups.length,
|
||||
},
|
||||
@ -210,6 +350,9 @@ async function main() {
|
||||
types,
|
||||
generations,
|
||||
species,
|
||||
moves,
|
||||
items,
|
||||
abilities,
|
||||
pokedexes,
|
||||
versionGroups,
|
||||
};
|
||||
|
||||
@ -3,15 +3,17 @@ import { Sprite } from './Sprite.js';
|
||||
import { TypeChip } from './TypeChip.js';
|
||||
import { entry, toggle } from '../store/selection.js';
|
||||
import { typeHex } from '../lib/type-color.js';
|
||||
import { typesForGen } from '../lib/type-resolve.js';
|
||||
|
||||
/**
|
||||
* One Pokémon in the dex feed. Tinted by its primary type: a soft top-down
|
||||
* gradient, a type-coloured spotlight behind an oversized sprite that lifts
|
||||
* above the card, a number chip, and a big ghost number in the corner.
|
||||
*/
|
||||
export function Card(species, number, { spriteStyle = 'official', versionGroup, boxed = false, metric = null } = {}) {
|
||||
export function Card(species, number, { spriteStyle = 'official', versionGroup, gen = 9, boxed = false, metric = null } = {}) {
|
||||
const state = entry(species.id);
|
||||
const mainType = (species.types || [])[0] || 'normal';
|
||||
const types = typesForGen(species, gen);
|
||||
const mainType = types[0] || 'normal';
|
||||
const num = String(number ?? species.id).padStart(3, '0');
|
||||
const data = { type: mainType, sprite: spriteStyle };
|
||||
if (boxed) data.boxed = '1';
|
||||
@ -58,7 +60,10 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup,
|
||||
{ class: 'card__body' },
|
||||
el('span', { class: 'card__num' }, `#${num}`),
|
||||
el('span', { class: 'card__name' }, species.name.replace(/-/g, ' ')),
|
||||
el('span', { class: 'card__types' }, ...(species.types || []).map(TypeChip)),
|
||||
el('span', { class: 'card__types' }, ...types.map(TypeChip)),
|
||||
species.forms?.length
|
||||
? el('span', { class: 'card__forms' }, `+${species.forms.length} form${species.forms.length > 1 ? 's' : ''}`)
|
||||
: null,
|
||||
metric ? el('span', { class: 'card__metric' }, metric) : null,
|
||||
),
|
||||
);
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { el } from '../lib/dom.js';
|
||||
import { openGameSheet } from './GameSheet.js';
|
||||
import { settings } from '../store/settings.js';
|
||||
|
||||
const ITEMS = [
|
||||
{ label: 'Dex', icon: '▦', href: '#/', match: (h) => h === '#/' || h === '' || h === '#' },
|
||||
{ label: 'Team', icon: '⛨', href: '#/team', match: (h) => h.startsWith('#/team'), optional: 'showTeamNav' },
|
||||
{ label: 'Games', icon: '◉', action: openGameSheet },
|
||||
{ label: 'Search', icon: '⌕', href: '#/search', match: (h) => h.startsWith('#/search') },
|
||||
{ label: 'Settings', icon: '⚙', href: '#/settings', match: (h) => h.startsWith('#/settings') },
|
||||
@ -10,41 +12,47 @@ const ITEMS = [
|
||||
|
||||
/**
|
||||
* One nav element, styled by CSS into a bottom bar on phones and a sidebar
|
||||
* on wide screens. Links derive their active state from the hash; the Games
|
||||
* item summons an overlay instead of navigating.
|
||||
* on wide screens. The Games item summons an overlay; optional items (Team)
|
||||
* can be hidden from Settings.
|
||||
*/
|
||||
export function Nav() {
|
||||
const links = ITEMS.map((item) => {
|
||||
const inner = [
|
||||
el('span', { class: 'nav__icon', 'aria-hidden': 'true' }, item.icon),
|
||||
el('span', { class: 'nav__label' }, item.label),
|
||||
];
|
||||
return item.action
|
||||
? el(
|
||||
'button',
|
||||
{ class: 'nav__link', type: 'button', onclick: () => item.action() },
|
||||
...inner,
|
||||
)
|
||||
: el('a', { class: 'nav__link', href: item.href }, ...inner);
|
||||
});
|
||||
const nav = el('nav', { class: 'nav', 'aria-label': 'Primary' });
|
||||
let links = [];
|
||||
|
||||
const nav = el(
|
||||
'nav',
|
||||
{ class: 'nav', 'aria-label': 'Primary' },
|
||||
el('span', { class: 'nav__brand' }, 'Pokédex'),
|
||||
...links,
|
||||
);
|
||||
function build() {
|
||||
const st = settings.get();
|
||||
const items = ITEMS.filter((it) => !it.optional || st[it.optional]);
|
||||
links = items.map((item) => {
|
||||
const inner = [
|
||||
el('span', { class: 'nav__icon', 'aria-hidden': 'true' }, item.icon),
|
||||
el('span', { class: 'nav__label' }, item.label),
|
||||
];
|
||||
return item.action
|
||||
? el('button', { class: 'nav__link', type: 'button', onclick: () => item.action() }, ...inner)
|
||||
: el('a', { class: 'nav__link', href: item.href }, ...inner);
|
||||
});
|
||||
nav.replaceChildren(el('span', { class: 'nav__brand' }, 'Pokédex'), ...links);
|
||||
nav._items = items;
|
||||
sync();
|
||||
}
|
||||
|
||||
function sync() {
|
||||
const hash = location.hash || '#/';
|
||||
ITEMS.forEach((item, i) => {
|
||||
(nav._items || []).forEach((item, i) => {
|
||||
if (!item.match) return;
|
||||
links[i].classList.toggle('is-active', item.match(hash));
|
||||
links[i].setAttribute('aria-current', item.match(hash) ? 'page' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
build();
|
||||
let lastShowTeam = settings.get().showTeamNav;
|
||||
settings.subscribe((s) => {
|
||||
if (s.showTeamNav !== lastShowTeam) {
|
||||
lastShowTeam = s.showTeamNav;
|
||||
build();
|
||||
}
|
||||
});
|
||||
window.addEventListener('hashchange', sync);
|
||||
sync();
|
||||
return nav;
|
||||
}
|
||||
|
||||
98
src/components/PokemonPicker.js
Normal file
98
src/components/PokemonPicker.js
Normal file
@ -0,0 +1,98 @@
|
||||
import { el, clear } from '../lib/dom.js';
|
||||
import { loadSnapshot } from '../data/snapshot.js';
|
||||
import { settings } from '../store/settings.js';
|
||||
import { Sprite } from './Sprite.js';
|
||||
import { TypeChip } from './TypeChip.js';
|
||||
|
||||
let openInstance = null;
|
||||
|
||||
/**
|
||||
* A summonable overlay to pick a Pokémon from the full snapshot list.
|
||||
* `onPick(id)` fires per selection; the sheet stays open so you can add
|
||||
* several. Dismiss with the backdrop, ✕, or Escape.
|
||||
*/
|
||||
export async function openPokemonPicker(onPick) {
|
||||
if (openInstance) return;
|
||||
const snap = await loadSnapshot();
|
||||
|
||||
const backdrop = el('div', {
|
||||
class: 'sheet-backdrop',
|
||||
onclick: (e) => {
|
||||
if (e.target === backdrop) close();
|
||||
},
|
||||
});
|
||||
const panel = el('div', {
|
||||
class: 'gsheet',
|
||||
role: 'dialog',
|
||||
'aria-modal': 'true',
|
||||
'aria-label': 'Add a Pokémon',
|
||||
});
|
||||
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') close();
|
||||
};
|
||||
function close() {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
backdrop.classList.remove('is-open');
|
||||
setTimeout(() => {
|
||||
backdrop.remove();
|
||||
openInstance = null;
|
||||
}, 220);
|
||||
}
|
||||
|
||||
const results = el('div', { class: 'picker__list' });
|
||||
const input = el('input', {
|
||||
class: 'search__input',
|
||||
type: 'search',
|
||||
placeholder: 'Name, number or type…',
|
||||
autofocus: true,
|
||||
oninput: (e) => run(e.target.value),
|
||||
});
|
||||
|
||||
function run(raw) {
|
||||
const q = raw.trim().toLowerCase();
|
||||
clear(results);
|
||||
if (!q) return;
|
||||
const style = settings.get().spriteStyle;
|
||||
const matches = snap.species
|
||||
.filter(
|
||||
(s) =>
|
||||
s.name.replace(/-/g, ' ').includes(q) ||
|
||||
String(s.id) === q ||
|
||||
(s.types || []).some((t) => t === q),
|
||||
)
|
||||
.slice(0, 50);
|
||||
for (const s of matches) {
|
||||
results.append(
|
||||
el(
|
||||
'button',
|
||||
{
|
||||
class: 'picker__row',
|
||||
type: 'button',
|
||||
onclick: () => onPick(s.id),
|
||||
},
|
||||
Sprite(s.id, { style, alt: s.name, size: 44 }),
|
||||
el('span', { class: 'search__num' }, `#${String(s.id).padStart(4, '0')}`),
|
||||
el('span', { class: 'search__name' }, s.name.replace(/-/g, ' ')),
|
||||
el('span', { class: 'search__types' }, ...(s.types || []).map(TypeChip)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
panel.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'gsheet__head' },
|
||||
el('h2', {}, 'Add a Pokémon'),
|
||||
el('button', { class: 'gsheet__close', type: 'button', 'aria-label': 'Close', onclick: close }, '✕'),
|
||||
),
|
||||
input,
|
||||
results,
|
||||
);
|
||||
backdrop.append(panel);
|
||||
document.body.append(backdrop);
|
||||
document.addEventListener('keydown', onKey);
|
||||
openInstance = backdrop;
|
||||
requestAnimationFrame(() => backdrop.classList.add('is-open'));
|
||||
}
|
||||
39
src/data/natures.js
Normal file
39
src/data/natures.js
Normal file
@ -0,0 +1,39 @@
|
||||
/**
|
||||
* The 25 natures. Each raises one stat by 10% and lowers another by 10%;
|
||||
* the five where up === down are neutral. HP is never affected.
|
||||
*/
|
||||
export const STAT_LABEL = {
|
||||
atk: 'Attack',
|
||||
def: 'Defense',
|
||||
spa: 'Sp. Atk',
|
||||
spd: 'Sp. Def',
|
||||
spe: 'Speed',
|
||||
};
|
||||
|
||||
export const NATURES = [
|
||||
{ name: 'Hardy', up: 'atk', down: 'atk' },
|
||||
{ name: 'Lonely', up: 'atk', down: 'def' },
|
||||
{ name: 'Brave', up: 'atk', down: 'spe' },
|
||||
{ name: 'Adamant', up: 'atk', down: 'spa' },
|
||||
{ name: 'Naughty', up: 'atk', down: 'spd' },
|
||||
{ name: 'Bold', up: 'def', down: 'atk' },
|
||||
{ name: 'Docile', up: 'def', down: 'def' },
|
||||
{ name: 'Relaxed', up: 'def', down: 'spe' },
|
||||
{ name: 'Impish', up: 'def', down: 'spa' },
|
||||
{ name: 'Lax', up: 'def', down: 'spd' },
|
||||
{ name: 'Timid', up: 'spe', down: 'atk' },
|
||||
{ name: 'Hasty', up: 'spe', down: 'def' },
|
||||
{ name: 'Serious', up: 'spe', down: 'spe' },
|
||||
{ name: 'Jolly', up: 'spe', down: 'spa' },
|
||||
{ name: 'Naive', up: 'spe', down: 'spd' },
|
||||
{ name: 'Modest', up: 'spa', down: 'atk' },
|
||||
{ name: 'Mild', up: 'spa', down: 'def' },
|
||||
{ name: 'Quiet', up: 'spa', down: 'spe' },
|
||||
{ name: 'Bashful', up: 'spa', down: 'spa' },
|
||||
{ name: 'Rash', up: 'spa', down: 'spd' },
|
||||
{ name: 'Calm', up: 'spd', down: 'atk' },
|
||||
{ name: 'Gentle', up: 'spd', down: 'def' },
|
||||
{ name: 'Sassy', up: 'spd', down: 'spe' },
|
||||
{ name: 'Careful', up: 'spd', down: 'spa' },
|
||||
{ name: 'Quirky', up: 'spd', down: 'spd' },
|
||||
];
|
||||
@ -20,6 +20,13 @@ export async function loadSnapshot() {
|
||||
data.pokedexByKey = new Map(data.pokedexes.map((d) => [d.key, d]));
|
||||
data.versionGroupByKey = new Map(data.versionGroups.map((v) => [v.key, v]));
|
||||
data.generationById = new Map(data.generations.map((g) => [g.id, g]));
|
||||
data.moves = data.moves || [];
|
||||
data.items = data.items || [];
|
||||
data.abilities = data.abilities || [];
|
||||
data.moveById = new Map(data.moves.map((m) => [m.id, m]));
|
||||
data.itemById = new Map(data.items.map((it) => [it.id, it]));
|
||||
data.abilityById = new Map(data.abilities.map((a) => [a.id, a]));
|
||||
data.itemCategories = [...new Set(data.items.map((it) => it.category))].sort();
|
||||
|
||||
cached = data;
|
||||
return cached;
|
||||
|
||||
@ -64,6 +64,31 @@ export function multiplier(attacking, defTypes, gen = 9) {
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Per-attacking-type multiplier map against a defender, e.g. { fire: 2, water: 0.5, ... }. */
|
||||
export function defenseVector(defTypes, gen = 9) {
|
||||
const out = {};
|
||||
const atk = gen < 6 ? TYPES.filter((t) => t !== 'fairy') : TYPES;
|
||||
for (const a of atk) out[a] = multiplier(a, defTypes, gen);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a Pokémon's STAB types hit, from the attacker's side.
|
||||
* Returns { strong: [types any STAB is 2x+ against], walls: [types that
|
||||
* resist every one of its STAB types] }.
|
||||
*/
|
||||
export function offensiveSummary(atkTypes, gen = 9) {
|
||||
const strong = [];
|
||||
const walls = [];
|
||||
const defs = gen < 6 ? TYPES.filter((t) => t !== 'fairy') : TYPES;
|
||||
for (const def of defs) {
|
||||
const mults = atkTypes.map((atk) => multiplier(atk, [def], gen));
|
||||
if (Math.max(...mults) >= 2) strong.push(def);
|
||||
else if (mults.every((m) => m <= 0.5)) walls.push(def);
|
||||
}
|
||||
return { strong, walls };
|
||||
}
|
||||
|
||||
/**
|
||||
* Defensive matchups for a Pokémon, bucketed by multiplier.
|
||||
* Returns { '4': [...types], '2': [...], '0.5': [...], '0.25': [...], '0': [...] }
|
||||
|
||||
13
src/lib/type-resolve.js
Normal file
13
src/lib/type-resolve.js
Normal file
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* A snapshot species' typing as it was in a given generation, using the
|
||||
* `pastTypes` history baked into the snapshot (Gen 1 Magnemite is pure
|
||||
* Electric, pre-Gen 6 Clefairy is Normal, …). Falls back to current types.
|
||||
*/
|
||||
export function typesForGen(species, gen = 9) {
|
||||
const past = species && species.pastTypes;
|
||||
if (past && past.length) {
|
||||
const era = [...past].sort((a, b) => a.gen - b.gen).find((p) => p.gen >= gen);
|
||||
if (era) return era.types;
|
||||
}
|
||||
return (species && species.types) || [];
|
||||
}
|
||||
@ -5,6 +5,9 @@ import { SearchView } from './views/SearchView.js';
|
||||
import { SettingsView } from './views/SettingsView.js';
|
||||
import { MoveDetail } from './views/MoveDetail.js';
|
||||
import { ItemDetail } from './views/ItemDetail.js';
|
||||
import { AbilityDetail } from './views/AbilityDetail.js';
|
||||
import { TeamView } from './views/TeamView.js';
|
||||
import { NaturesView } from './views/NaturesView.js';
|
||||
import { detailSkeleton, lookupSkeleton } from './components/skeletons.js';
|
||||
|
||||
const routes = [
|
||||
@ -12,6 +15,9 @@ const routes = [
|
||||
{ pattern: /^#\/pokemon\/(\d+)$/, view: (m) => PokemonDetail(Number(m[1])), skeleton: detailSkeleton },
|
||||
{ pattern: /^#\/move\/(\d+)$/, view: (m) => MoveDetail(Number(m[1])), skeleton: lookupSkeleton },
|
||||
{ pattern: /^#\/item\/(\d+)$/, view: (m) => ItemDetail(Number(m[1])), skeleton: lookupSkeleton },
|
||||
{ pattern: /^#\/ability\/(\d+)$/, view: (m) => AbilityDetail(Number(m[1])), skeleton: lookupSkeleton },
|
||||
{ pattern: /^#\/team$/, view: () => TeamView() },
|
||||
{ pattern: /^#\/natures$/, view: () => NaturesView() },
|
||||
{ pattern: /^#\/search$/, view: () => SearchView() },
|
||||
{ pattern: /^#\/settings$/, view: () => SettingsView() },
|
||||
];
|
||||
|
||||
@ -17,6 +17,7 @@ export const settings = createStore('pdx.settings', {
|
||||
accent: 'red', // red | blue | green | amber | violet | rose
|
||||
spriteStyle: 'official',
|
||||
showShiny: false,
|
||||
showTeamNav: true,
|
||||
locale: 'en',
|
||||
});
|
||||
|
||||
|
||||
28
src/store/team.js
Normal file
28
src/store/team.js
Normal file
@ -0,0 +1,28 @@
|
||||
import { createStore } from './createStore.js';
|
||||
|
||||
/**
|
||||
* The working "lineup" — up to 6 Pokémon (national dex ids) analysed by the
|
||||
* Team view (coverage) and Compare view. Persisted.
|
||||
*/
|
||||
export const team = createStore('pdx.team', { members: [] });
|
||||
|
||||
export const MAX_TEAM = 6;
|
||||
|
||||
export function addToTeam(id) {
|
||||
team.set((s) => {
|
||||
if (s.members.includes(id) || s.members.length >= MAX_TEAM) return s;
|
||||
return { ...s, members: [...s.members, id] };
|
||||
});
|
||||
}
|
||||
|
||||
export function removeFromTeam(id) {
|
||||
team.set((s) => ({ ...s, members: s.members.filter((x) => x !== id) }));
|
||||
}
|
||||
|
||||
export function clearTeam() {
|
||||
team.set({ members: [] });
|
||||
}
|
||||
|
||||
export function inTeam(id) {
|
||||
return team.get().members.includes(id);
|
||||
}
|
||||
@ -13,4 +13,16 @@ export const ui = createStore('pdx.ui', {
|
||||
sort: 'dex',
|
||||
sortDesc: false,
|
||||
filter: 'all',
|
||||
filterType: '',
|
||||
filterGen: 0,
|
||||
recent: [],
|
||||
toolsOpen: false,
|
||||
teamMode: 'coverage',
|
||||
mvType: '',
|
||||
mvClass: '',
|
||||
mvSort: 'name',
|
||||
mvTm: false,
|
||||
itCat: '',
|
||||
itSort: 'name',
|
||||
abSort: 'name',
|
||||
});
|
||||
|
||||
@ -1825,3 +1825,608 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Feed: type/gen filters, recent strip -------------------- */
|
||||
.feed-filters {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.feed-filters .feed-sort__select {
|
||||
max-width: none;
|
||||
flex: 1;
|
||||
}
|
||||
.recent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.recent::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.recent[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.recent__label {
|
||||
flex: none;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.recent__item {
|
||||
flex: none;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-2);
|
||||
transition: transform 0.12s var(--ease-spring);
|
||||
}
|
||||
.recent__item:hover {
|
||||
transform: translateY(-2px);
|
||||
background: color-mix(in srgb, var(--accent) 20%, var(--surface-2));
|
||||
}
|
||||
.recent__item .sprite {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* ---- Detail: gender bar, cry button ------------------------- */
|
||||
.gender {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.gender-bar {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#4d90d5 calc(100% - var(--f, 0%)),
|
||||
#ec8fe6 calc(100% - var(--f, 0%))
|
||||
);
|
||||
}
|
||||
.gender span {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.phero__cry {
|
||||
font-size: 0.7rem;
|
||||
padding: 6px 11px;
|
||||
}
|
||||
|
||||
/* ---- Feed: collapsible tools + compact header on phones ------ */
|
||||
.feed-tools__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding: 8px 14px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
color: var(--text-dim);
|
||||
font: inherit;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.feed-tools__chev {
|
||||
transition: transform 0.15s ease;
|
||||
margin-left: auto;
|
||||
}
|
||||
.feed-tools__toggle[aria-expanded="true"] .feed-tools__chev {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.feed-tools__toggle[data-active]:not([data-active=""])::before {
|
||||
content: attr(data-active);
|
||||
min-width: 17px;
|
||||
height: 17px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
font-size: 0.68rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.feed-tools {
|
||||
display: none;
|
||||
}
|
||||
.feed-tools.is-open {
|
||||
display: block;
|
||||
}
|
||||
.feed-tools > * {
|
||||
margin-top: 10px;
|
||||
}
|
||||
@media (min-width: 900px) {
|
||||
.feed-tools__toggle {
|
||||
display: none;
|
||||
}
|
||||
.feed-tools {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.feed-head {
|
||||
margin: -8px 0 12px;
|
||||
padding: 10px 0 8px;
|
||||
}
|
||||
.feed-head__id h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.feed-head__game {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.ring {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
.ring__label {
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
.field-search {
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
.field-search__input {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Shorter cards so more fit on screen */
|
||||
.grid {
|
||||
gap: 12px;
|
||||
}
|
||||
.card {
|
||||
padding: 12px 12px 12px;
|
||||
}
|
||||
.card__art {
|
||||
min-height: 116px;
|
||||
padding-top: 22px;
|
||||
}
|
||||
.card__art .sprite {
|
||||
width: 94px;
|
||||
height: 94px;
|
||||
}
|
||||
.card__ghost {
|
||||
font-size: 2.3rem;
|
||||
}
|
||||
.card__spot {
|
||||
top: 24px;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
.card__body {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.card__name {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= Team / Compare ================================= */
|
||||
.lineup {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
margin: 14px 0 22px;
|
||||
}
|
||||
.lineup__slot {
|
||||
position: relative;
|
||||
width: 88px;
|
||||
padding: 8px 6px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
text-align: center;
|
||||
}
|
||||
.lineup__x {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.lineup__link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.lineup__link .sprite {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.lineup__name {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: capitalize;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lineup__add {
|
||||
width: 88px;
|
||||
min-height: 106px;
|
||||
border: 1.5px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 1.6rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.lineup__add span {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.lineup__clear {
|
||||
align-self: center;
|
||||
border: none;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-dim);
|
||||
border-radius: 999px;
|
||||
padding: 6px 14px;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ---- Coverage table -------------------------------------------- */
|
||||
.cov {
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.cov__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: max-content;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.cov__row--head {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.cov__type {
|
||||
width: 74px;
|
||||
flex: none;
|
||||
}
|
||||
.cov__mem {
|
||||
width: 40px;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.cov__mem .sprite {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.cov__cell {
|
||||
width: 40px;
|
||||
flex: none;
|
||||
text-align: center;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
padding: 5px 0;
|
||||
border-radius: 6px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.cov__cell.m4 { background: color-mix(in srgb, var(--danger) 60%, transparent); color: #fff; }
|
||||
.cov__cell.m2 { background: color-mix(in srgb, var(--danger) 28%, transparent); }
|
||||
.cov__cell.m05 { background: color-mix(in srgb, var(--good) 24%, transparent); }
|
||||
.cov__cell.m025 { background: color-mix(in srgb, var(--good) 48%, transparent); color: #fff; }
|
||||
.cov__cell.m0 { background: var(--surface-2); color: var(--text-dim); }
|
||||
.cov__sum {
|
||||
width: 42px;
|
||||
flex: none;
|
||||
text-align: center;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.cov__sum.is-bad {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ---- Compare table ------------------------------------------- */
|
||||
.cmp {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.cmp__row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
min-width: max-content;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.cmp__row--head {
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
.cmp__row--bst {
|
||||
font-weight: 800;
|
||||
border-bottom: none;
|
||||
}
|
||||
.cmp__label {
|
||||
width: 72px;
|
||||
flex: none;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
align-self: center;
|
||||
}
|
||||
.cmp__mem {
|
||||
width: 96px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: capitalize;
|
||||
text-align: center;
|
||||
}
|
||||
.cmp__mem .sprite {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.cmp__cell {
|
||||
width: 96px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.cmp__stat {
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.cmp__val {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.cmp__stat.is-max .cmp__val {
|
||||
color: var(--good);
|
||||
}
|
||||
.cmp__track {
|
||||
width: 72px;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
.cmp__bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--type-main, var(--accent));
|
||||
}
|
||||
.cmp__stat.is-max .cmp__bar {
|
||||
background: var(--good);
|
||||
}
|
||||
|
||||
/* ---- Picker + team button ---------------------------------- */
|
||||
.picker__list {
|
||||
overflow-y: auto;
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.picker__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.picker__row:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.picker__row .sprite {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: none;
|
||||
object-fit: contain;
|
||||
}
|
||||
.ptrack__btn--team.is-on {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
.ptrack__btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ---- Lookup filter bar ------------------------------------- */
|
||||
.lookup__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.lookup__filters .feed-sort__select {
|
||||
flex: 1 1 8rem;
|
||||
max-width: none;
|
||||
}
|
||||
.lookup__check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.search__cat {
|
||||
flex: none;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
background: var(--surface-2);
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
/* ---- Abilities ---------------------------------------------- */
|
||||
.search__row--ability {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 10px;
|
||||
padding: 11px 14px;
|
||||
}
|
||||
.search__ab-eff {
|
||||
flex-basis: 100%;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ab-mons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.ab-mon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 8px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
text-align: center;
|
||||
}
|
||||
.ab-mon:hover {
|
||||
background: color-mix(in srgb, var(--accent) 18%, var(--surface-2));
|
||||
}
|
||||
.ab-mon .sprite {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* ---- Natures table --------------------------------------- */
|
||||
.natures {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.natures__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 8px;
|
||||
padding: 9px 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.natures__row--head {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.natures__name {
|
||||
font-weight: 700;
|
||||
}
|
||||
.natures__up {
|
||||
color: var(--good);
|
||||
font-weight: 600;
|
||||
}
|
||||
.natures__down {
|
||||
color: var(--danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
.natures__row.is-neutral {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.natures__row.is-neutral .natures__up,
|
||||
.natures__row.is-neutral .natures__down {
|
||||
color: var(--text-dim);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ---- Forms ------------------------------------------------- */
|
||||
.phero__form {
|
||||
font-weight: 500;
|
||||
font-size: 0.62em;
|
||||
opacity: 0.85;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.phero__form:empty {
|
||||
display: none;
|
||||
}
|
||||
.formbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.formbar__pill {
|
||||
appearance: none;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.35);
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.formbar__pill.is-active {
|
||||
background: #fff;
|
||||
border-color: #fff;
|
||||
color: var(--type-main);
|
||||
}
|
||||
.card__forms {
|
||||
margin-top: 4px;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
color: color-mix(in srgb, var(--type-main) 55%, var(--text-dim));
|
||||
}
|
||||
|
||||
@ -34,7 +34,7 @@ registerRoute(
|
||||
registerRoute(
|
||||
({ url }) =>
|
||||
url.origin === 'https://raw.githubusercontent.com' &&
|
||||
url.pathname.includes('/PokeAPI/sprites/'),
|
||||
(url.pathname.includes('/PokeAPI/sprites/') || url.pathname.includes('/PokeAPI/cries/')),
|
||||
new CacheFirst({
|
||||
cacheName: 'pokeapi-img',
|
||||
plugins: [
|
||||
|
||||
66
src/views/AbilityDetail.js
Normal file
66
src/views/AbilityDetail.js
Normal file
@ -0,0 +1,66 @@
|
||||
import { el, clear } from '../lib/dom.js';
|
||||
import { loadSnapshot } from '../data/snapshot.js';
|
||||
import { settings } from '../store/settings.js';
|
||||
import { Sprite } from '../components/Sprite.js';
|
||||
|
||||
export async function AbilityDetail(id) {
|
||||
const view = el('section', { class: 'view lookup' });
|
||||
const snap = await loadSnapshot();
|
||||
const a = snap.abilityById.get(id);
|
||||
|
||||
if (!a) {
|
||||
view.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'view--error' },
|
||||
el('h1', {}, 'Unknown ability'),
|
||||
el('a', { class: 'button', href: '#/search' }, 'Back to lookup'),
|
||||
),
|
||||
);
|
||||
return view;
|
||||
}
|
||||
|
||||
const style = settings.get().spriteStyle;
|
||||
const mons = a.pokemon.map((pid) => snap.speciesById.get(pid)).filter(Boolean);
|
||||
mons.sort((x, y) => x.id - y.id);
|
||||
|
||||
clear(view).append(
|
||||
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/search' }, '‹ Lookup')),
|
||||
el(
|
||||
'header',
|
||||
{ class: 'lookup__head lookup__head--item' },
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
el('h1', {}, a.name.replace(/-/g, ' ')),
|
||||
el('span', { class: 'lookup__class' }, `Introduced Gen ${a.generation}`),
|
||||
),
|
||||
),
|
||||
el(
|
||||
'section',
|
||||
{ class: 'detail__section' },
|
||||
el('h2', {}, 'Effect'),
|
||||
el('p', {}, a.effect || 'No description available.'),
|
||||
),
|
||||
el(
|
||||
'section',
|
||||
{ class: 'detail__section' },
|
||||
el('h2', {}, `Pokémon with this ability (${mons.length})`),
|
||||
mons.length
|
||||
? el(
|
||||
'div',
|
||||
{ class: 'ab-mons' },
|
||||
...mons.map((sp) =>
|
||||
el(
|
||||
'a',
|
||||
{ class: 'ab-mon', href: `#/pokemon/${sp.id}` },
|
||||
Sprite(sp.id, { style, alt: sp.name, size: 48 }),
|
||||
el('span', {}, sp.name.replace(/-/g, ' ')),
|
||||
),
|
||||
),
|
||||
)
|
||||
: el('p', { class: 'detail__muted' }, 'None on record.'),
|
||||
),
|
||||
);
|
||||
return view;
|
||||
}
|
||||
@ -6,6 +6,13 @@ import { ui } from '../store/ui.js';
|
||||
import { selection, stats } from '../store/selection.js';
|
||||
import { Card } from '../components/Card.js';
|
||||
import { ProgressRing } from '../components/ProgressRing.js';
|
||||
import { Sprite } from '../components/Sprite.js';
|
||||
import { typesForGen } from '../lib/type-resolve.js';
|
||||
|
||||
const TYPES = [
|
||||
'normal', 'fire', 'water', 'electric', 'grass', 'ice', 'fighting', 'poison',
|
||||
'ground', 'flying', 'psychic', 'bug', 'rock', 'ghost', 'dragon', 'dark', 'steel', 'fairy',
|
||||
];
|
||||
|
||||
const FILTERS = [
|
||||
{ key: 'all', label: 'All', test: () => true },
|
||||
@ -73,10 +80,13 @@ export async function DexGrid() {
|
||||
const grid = el('div', { class: 'grid' });
|
||||
let query = '';
|
||||
let filterKey = ui.get().filter || 'all';
|
||||
let filterType = ui.get().filterType || '';
|
||||
let filterGen = Number(ui.get().filterGen) || 0;
|
||||
let sortKey = ui.get().sort || 'dex';
|
||||
let sortDesc = !!ui.get().sortDesc;
|
||||
let rows = [];
|
||||
let ids = [];
|
||||
let lastList = [];
|
||||
|
||||
const ring = ProgressRing();
|
||||
|
||||
@ -109,6 +119,7 @@ export async function DexGrid() {
|
||||
chipEls.forEach((c, i) => c.btn.classList.toggle('is-active', FILTERS[i].key === f.key));
|
||||
paintGrid();
|
||||
resetScroll();
|
||||
syncToolsBadge();
|
||||
},
|
||||
},
|
||||
el('span', {}, f.label),
|
||||
@ -151,6 +162,54 @@ export async function DexGrid() {
|
||||
}
|
||||
syncDirBtn();
|
||||
|
||||
const typeSelect = el(
|
||||
'select',
|
||||
{
|
||||
class: 'feed-sort__select',
|
||||
onchange: (e) => {
|
||||
filterType = e.target.value;
|
||||
ui.set({ filterType });
|
||||
paintGrid();
|
||||
resetScroll();
|
||||
syncToolsBadge();
|
||||
},
|
||||
},
|
||||
el('option', { value: '' }, 'Any type'),
|
||||
...TYPES.map((t) => el('option', { value: t, selected: t === filterType }, prettify(t))),
|
||||
);
|
||||
const genSelect = el(
|
||||
'select',
|
||||
{
|
||||
class: 'feed-sort__select',
|
||||
onchange: (e) => {
|
||||
filterGen = Number(e.target.value);
|
||||
ui.set({ filterGen });
|
||||
paintGrid();
|
||||
resetScroll();
|
||||
syncToolsBadge();
|
||||
},
|
||||
},
|
||||
el('option', { value: '0' }, 'Any gen'),
|
||||
...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((g) =>
|
||||
el('option', { value: String(g), selected: g === filterGen }, `Gen ${g}`),
|
||||
),
|
||||
);
|
||||
const randomBtn = el(
|
||||
'button',
|
||||
{
|
||||
class: 'feed-sort__dir',
|
||||
type: 'button',
|
||||
title: 'Random Pokémon',
|
||||
onclick: () => {
|
||||
if (lastList.length) {
|
||||
const pick = lastList[Math.floor(Math.random() * lastList.length)];
|
||||
location.hash = `#/pokemon/${pick.species.id}`;
|
||||
}
|
||||
},
|
||||
},
|
||||
'🎲',
|
||||
);
|
||||
|
||||
// Changing the list order/contents makes the old scroll offset meaningless.
|
||||
function resetScroll() {
|
||||
ui.set({ feedScroll: 0 });
|
||||
@ -175,6 +234,58 @@ export async function DexGrid() {
|
||||
if (document.fonts && document.fonts.ready) document.fonts.ready.then(go);
|
||||
}
|
||||
|
||||
const recentStrip = el('div', { class: 'recent' });
|
||||
function renderRecent() {
|
||||
const rec = (ui.get().recent || [])
|
||||
.map((id) => snap.speciesById.get(id))
|
||||
.filter(Boolean)
|
||||
.slice(0, 12);
|
||||
clear(recentStrip);
|
||||
recentStrip.hidden = rec.length === 0;
|
||||
if (!rec.length) return;
|
||||
recentStrip.append(el('span', { class: 'recent__label' }, 'Recent'));
|
||||
const style = settings.get().spriteStyle;
|
||||
for (const sp of rec) {
|
||||
recentStrip.append(
|
||||
el(
|
||||
'a',
|
||||
{ class: 'recent__item', href: `#/pokemon/${sp.id}`, title: sp.name.replace(/-/g, ' ') },
|
||||
Sprite(sp.id, { style, alt: sp.name, size: 44 }),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
renderRecent();
|
||||
|
||||
const tools = el(
|
||||
'div',
|
||||
{ class: `feed-tools${ui.get().toolsOpen ? ' is-open' : ''}` },
|
||||
filterBar,
|
||||
el('div', { class: 'feed-sort' }, el('span', { class: 'feed-sort__label' }, 'Sort'), sortSelect, dirBtn),
|
||||
el('div', { class: 'feed-sort feed-filters' }, typeSelect, genSelect, randomBtn),
|
||||
);
|
||||
const toolsToggle = el(
|
||||
'button',
|
||||
{
|
||||
class: 'feed-tools__toggle',
|
||||
type: 'button',
|
||||
'aria-expanded': String(!!ui.get().toolsOpen),
|
||||
onclick: () => {
|
||||
const open = tools.classList.toggle('is-open');
|
||||
toolsToggle.setAttribute('aria-expanded', String(open));
|
||||
ui.set({ toolsOpen: open });
|
||||
},
|
||||
},
|
||||
'Filters & sort',
|
||||
el('span', { class: 'feed-tools__chev', 'aria-hidden': 'true' }, '▾'),
|
||||
);
|
||||
function syncToolsBadge() {
|
||||
const n =
|
||||
(filterKey !== 'all' ? 1 : 0) + (filterType ? 1 : 0) + (filterGen ? 1 : 0);
|
||||
toolsToggle.dataset.active = n ? String(n) : '';
|
||||
}
|
||||
syncToolsBadge();
|
||||
|
||||
view.append(
|
||||
el(
|
||||
'header',
|
||||
@ -191,15 +302,10 @@ export async function DexGrid() {
|
||||
el('span', { class: 'field-search__icon', html: SEARCH_ICON, 'aria-hidden': 'true' }),
|
||||
search,
|
||||
),
|
||||
filterBar,
|
||||
el(
|
||||
'div',
|
||||
{ class: 'feed-sort' },
|
||||
el('span', { class: 'feed-sort__label' }, 'Sort'),
|
||||
sortSelect,
|
||||
dirBtn,
|
||||
),
|
||||
toolsToggle,
|
||||
tools,
|
||||
),
|
||||
recentStrip,
|
||||
grid,
|
||||
);
|
||||
|
||||
@ -228,9 +334,8 @@ export async function DexGrid() {
|
||||
function paintGrid() {
|
||||
const st = settings.get();
|
||||
const pokemonState = selection.get().pokemon;
|
||||
const boxed =
|
||||
st.spriteStyle === 'game' &&
|
||||
(snap.versionGroupByKey.get(st.versionGroup)?.generation ?? 9) <= 2;
|
||||
const gen = snap.versionGroupByKey.get(st.versionGroup)?.generation ?? 9;
|
||||
const boxed = st.spriteStyle === 'game' && gen <= 2;
|
||||
const filterTest = FILTERS.find((f) => f.key === filterKey).test;
|
||||
|
||||
let list = rows.filter(({ species, number }) => {
|
||||
@ -242,6 +347,8 @@ export async function DexGrid() {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (filterGen && species.generation !== filterGen) return false;
|
||||
if (filterType && !typesForGen(species, gen).includes(filterType)) return false;
|
||||
const e = pokemonState[species.id] || { seen: false, caught: false, favorite: false };
|
||||
return filterTest(e, species);
|
||||
});
|
||||
@ -255,12 +362,14 @@ export async function DexGrid() {
|
||||
return (a.row.number ?? a.row.species.id) - (b.row.number ?? b.row.species.id);
|
||||
})
|
||||
.map((x) => x.row);
|
||||
lastList = list;
|
||||
|
||||
const frag = document.createDocumentFragment();
|
||||
list.forEach(({ species, number }, i) => {
|
||||
const card = Card(species, number, {
|
||||
spriteStyle: st.spriteStyle,
|
||||
versionGroup: st.versionGroup,
|
||||
gen,
|
||||
boxed,
|
||||
metric: metricLabel(sortKey, species),
|
||||
});
|
||||
|
||||
36
src/views/NaturesView.js
Normal file
36
src/views/NaturesView.js
Normal file
@ -0,0 +1,36 @@
|
||||
import { el } from '../lib/dom.js';
|
||||
import { NATURES, STAT_LABEL } from '../data/natures.js';
|
||||
|
||||
export function NaturesView() {
|
||||
const view = el('section', { class: 'view lookup' });
|
||||
view.append(
|
||||
el(
|
||||
'header',
|
||||
{ class: 'view__header' },
|
||||
el('h1', {}, 'Natures'),
|
||||
el('p', {}, 'Each nature raises one stat by 10% and lowers another by 10%. HP is never affected; five natures are neutral.'),
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'natures' },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'natures__row natures__row--head' },
|
||||
el('span', {}, 'Nature'),
|
||||
el('span', { class: 'natures__up' }, '▲ raises'),
|
||||
el('span', { class: 'natures__down' }, '▼ lowers'),
|
||||
),
|
||||
...NATURES.map((n) => {
|
||||
const neutral = n.up === n.down;
|
||||
return el(
|
||||
'div',
|
||||
{ class: `natures__row${neutral ? ' is-neutral' : ''}` },
|
||||
el('span', { class: 'natures__name' }, n.name),
|
||||
el('span', { class: 'natures__up' }, neutral ? '—' : STAT_LABEL[n.up]),
|
||||
el('span', { class: 'natures__down' }, neutral ? '—' : STAT_LABEL[n.down]),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
return view;
|
||||
}
|
||||
@ -6,6 +6,7 @@ import { getPokemon, getSpecies, getEvolutionChain, getEncounters } from '../dat
|
||||
import { settings } from '../store/settings.js';
|
||||
import { ui } from '../store/ui.js';
|
||||
import { entry, toggle } from '../store/selection.js';
|
||||
import { team, addToTeam, removeFromTeam, inTeam, MAX_TEAM } from '../store/team.js';
|
||||
import { Sprite } from '../components/Sprite.js';
|
||||
import { TypeChip } from '../components/TypeChip.js';
|
||||
import { StatBar } from '../components/StatBar.js';
|
||||
@ -15,9 +16,47 @@ import { TypeMatchups } from '../components/TypeMatchups.js';
|
||||
import { Locations } from '../components/Locations.js';
|
||||
import { FlavorText } from '../components/FlavorText.js';
|
||||
import { typeHex } from '../lib/type-color.js';
|
||||
import { offensiveSummary } from '../data/type-chart.js';
|
||||
|
||||
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
|
||||
|
||||
const STAT_LABEL = {
|
||||
hp: 'HP',
|
||||
attack: 'Atk',
|
||||
defense: 'Def',
|
||||
'special-attack': 'SpA',
|
||||
'special-defense': 'SpD',
|
||||
speed: 'Spe',
|
||||
};
|
||||
const GROWTH_EXP = {
|
||||
erratic: 600000,
|
||||
fast: 800000,
|
||||
'medium-fast': 1000000,
|
||||
'medium-slow': 1059860,
|
||||
slow: 1250000,
|
||||
fluctuating: 1640000,
|
||||
};
|
||||
|
||||
/** "87.5% ♀ 12.5% ♂" or "Genderless", plus a two-tone bar. */
|
||||
function genderCell(rate) {
|
||||
if (rate == null || rate < 0) return el('dd', {}, 'Genderless');
|
||||
const female = (rate / 8) * 100;
|
||||
const bar = el('div', { class: 'gender-bar' });
|
||||
bar.style.setProperty('--f', `${female}%`);
|
||||
return el(
|
||||
'dd',
|
||||
{ class: 'gender' },
|
||||
bar,
|
||||
el(
|
||||
'span',
|
||||
{},
|
||||
female < 100 ? `${(100 - female).toFixed(female % 12.5 ? 1 : 0)}% ♂` : '',
|
||||
female > 0 && female < 100 ? ' · ' : '',
|
||||
female > 0 ? `${female.toFixed(female % 12.5 ? 1 : 0)}% ♀` : '',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** A Pokémon's typing as it was in the selected game's generation. */
|
||||
function typesForGeneration(pokemon, gen) {
|
||||
const past = (pokemon.past_types || [])
|
||||
@ -81,11 +120,26 @@ export async function PokemonDetail(nationalId) {
|
||||
return view;
|
||||
}
|
||||
|
||||
const types = typesForGeneration(pokemon, vgGen);
|
||||
const mainType = types[0];
|
||||
view.style.setProperty('--type-main', typeHex(mainType));
|
||||
view.style.setProperty('--type-2', typeHex(types[1] || mainType));
|
||||
view.dataset.type = mainType;
|
||||
// Recently-viewed history for the feed strip (most recent first, capped).
|
||||
ui.set((s) => ({
|
||||
recent: [nationalId, ...(s.recent || []).filter((x) => x !== nationalId)].slice(0, 12),
|
||||
}));
|
||||
|
||||
// Form state: null = the base Pokémon; otherwise a snapshot form entry
|
||||
// whose full /pokemon data lives in `pk`.
|
||||
const snapSpecies = snap.speciesById.get(nationalId);
|
||||
const forms = (snapSpecies && snapSpecies.forms) || [];
|
||||
let activeForm = null;
|
||||
let pk = pokemon;
|
||||
|
||||
let types = typesForGeneration(pk, vgGen);
|
||||
let mainType = types[0];
|
||||
function applyThemeVars() {
|
||||
view.style.setProperty('--type-main', typeHex(mainType));
|
||||
view.style.setProperty('--type-2', typeHex(types[1] || mainType));
|
||||
view.dataset.type = mainType;
|
||||
}
|
||||
applyThemeVars();
|
||||
|
||||
// ---- Artwork + shiny --------------------------------------------
|
||||
// Shiny Pokémon were introduced in Gen 2 — no toggle for Gen 1 games.
|
||||
@ -107,9 +161,28 @@ export async function PokemonDetail(nationalId) {
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const cryUrl = pokemon.cries?.latest || pokemon.cries?.legacy;
|
||||
const cryBtn = cryUrl
|
||||
? el(
|
||||
'button',
|
||||
{
|
||||
class: 'phero__shiny phero__cry',
|
||||
type: 'button',
|
||||
title: 'Play cry',
|
||||
'aria-label': 'Play cry',
|
||||
onclick: () => {
|
||||
const a = new Audio(cryUrl);
|
||||
a.volume = 0.45;
|
||||
a.play().catch(() => {});
|
||||
},
|
||||
},
|
||||
'►',
|
||||
)
|
||||
: null;
|
||||
function paintArt() {
|
||||
artHolder.replaceChildren(
|
||||
Sprite(nationalId, {
|
||||
Sprite(activeForm ? activeForm.id : nationalId, {
|
||||
style: artStyle,
|
||||
shiny,
|
||||
versionGroup: st.versionGroup,
|
||||
@ -147,6 +220,24 @@ export async function PokemonDetail(nationalId) {
|
||||
b.append(label);
|
||||
return b;
|
||||
}
|
||||
|
||||
const teamBtn = el('button', {
|
||||
class: 'ptrack__btn ptrack__btn--team',
|
||||
type: 'button',
|
||||
onclick: () => {
|
||||
if (inTeam(nationalId)) removeFromTeam(nationalId);
|
||||
else addToTeam(nationalId);
|
||||
syncTeamBtn();
|
||||
},
|
||||
});
|
||||
function syncTeamBtn() {
|
||||
const on = inTeam(nationalId);
|
||||
const full = team.get().members.length >= MAX_TEAM;
|
||||
teamBtn.textContent = on ? '✓ In team' : full ? 'Team full' : '+ Team';
|
||||
teamBtn.classList.toggle('is-on', on);
|
||||
teamBtn.disabled = !on && full;
|
||||
}
|
||||
syncTeamBtn();
|
||||
function syncTrack() {
|
||||
for (const [field, b] of [
|
||||
['favorite', favBtn],
|
||||
@ -160,69 +251,132 @@ export async function PokemonDetail(nationalId) {
|
||||
}
|
||||
syncTrack();
|
||||
|
||||
// ---- About panel -----------------------------------------------
|
||||
const abilities = pokemon.abilities
|
||||
.filter((a) => !a.is_hidden || vgGen >= 5)
|
||||
.map((a) => prettify(a.ability.name) + (a.is_hidden ? ' (hidden)' : ''));
|
||||
const allGames = st.versionGroup === 'all';
|
||||
let statBars = [];
|
||||
|
||||
// ---- Form-dependent content (rebuilt when a form is selected) -----
|
||||
function formFactsNode(p) {
|
||||
const abilityList = p.abilities.filter((a) => !a.is_hidden || vgGen >= 5);
|
||||
const abilitiesNode = el(
|
||||
'span',
|
||||
{},
|
||||
...abilityList.flatMap((a, i) => {
|
||||
const link = el(
|
||||
'a',
|
||||
{ class: 'link', href: `#/ability/${idFromUrl(a.ability.url)}` },
|
||||
prettify(a.ability.name) + (a.is_hidden ? ' (hidden)' : ''),
|
||||
);
|
||||
return i === 0 ? [link] : [', ', link];
|
||||
}),
|
||||
);
|
||||
const evYield =
|
||||
p.stats.filter((s) => s.effort > 0).map((s) => `${s.effort} ${STAT_LABEL[s.stat.name] || s.stat.name}`).join(', ') ||
|
||||
'—';
|
||||
const heldItems = p.held_items?.map((h) => prettify(h.item.name)).join(', ') || '—';
|
||||
return el(
|
||||
'div',
|
||||
{},
|
||||
el(
|
||||
'dl',
|
||||
{ class: 'pfacts' },
|
||||
fact('Height', `${(p.height / 10).toFixed(1)} m`),
|
||||
fact('Weight', `${(p.weight / 10).toFixed(1)} kg`),
|
||||
abilityList.length && vgGen >= 3 ? fact('Abilities', abilitiesNode) : null,
|
||||
fact('Introduced', prettify(species.generation.name)),
|
||||
species.genera?.length
|
||||
? fact('Category', (species.genera.find((g) => g.language.name === 'en') || {}).genus || '—')
|
||||
: null,
|
||||
),
|
||||
el('h3', { class: 'ppanel__sub' }, 'Training'),
|
||||
el(
|
||||
'dl',
|
||||
{ class: 'pfacts' },
|
||||
fact('EV yield', evYield),
|
||||
fact('Base EXP', p.base_experience ?? '—'),
|
||||
fact(
|
||||
'Catch rate',
|
||||
species.capture_rate != null
|
||||
? `${species.capture_rate} / 255 (~${Math.round((species.capture_rate / 255) * 100)}% max)`
|
||||
: '—',
|
||||
),
|
||||
fact('Base friendship', species.base_happiness ?? '—'),
|
||||
fact(
|
||||
'Growth rate',
|
||||
species.growth_rate?.name
|
||||
? `${prettify(species.growth_rate.name)}${
|
||||
GROWTH_EXP[species.growth_rate.name]
|
||||
? ` · ${GROWTH_EXP[species.growth_rate.name].toLocaleString()} EXP`
|
||||
: ''
|
||||
}`
|
||||
: '—',
|
||||
),
|
||||
fact('Held items', heldItems),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function statsContent(p) {
|
||||
const stat = (name) => p.stats.find((s) => s.stat.name === name)?.base_stat ?? 0;
|
||||
const total = STAT_NAMES.reduce((sum, n) => sum + stat(n), 0);
|
||||
statBars = STAT_NAMES.map((n) => StatBar(n, stat(n)));
|
||||
return [
|
||||
el('div', { class: 'pstats' }, ...statBars),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'statbar statbar--total' },
|
||||
el('span', { class: 'statbar__label' }, 'Total'),
|
||||
el('span', { class: 'statbar__value' }, String(total)),
|
||||
el('div', { class: 'statbar__track' }),
|
||||
),
|
||||
el('h3', { class: 'ppanel__sub' }, 'Type matchups (defending)'),
|
||||
TypeMatchups(types, vgGen),
|
||||
offensiveBlock(types, vgGen),
|
||||
];
|
||||
}
|
||||
|
||||
function movesContent(p) {
|
||||
return allGames
|
||||
? el('p', { class: 'detail__muted' }, 'Pick a game to see its learnset.')
|
||||
: MovesList(p.moves, {
|
||||
versionGroupKey: st.versionGroup,
|
||||
gen: vgGen,
|
||||
genOfVg: (name) => snap.versionGroupByKey.get(name)?.generation ?? 9,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- About panel (FlavorText + form facts + species breeding) ----
|
||||
const eggGroups = species.egg_groups.map((g) => prettify(g.name));
|
||||
const hatch = species.hatch_counter;
|
||||
const factsHolder = el('div', {}, formFactsNode(pk));
|
||||
const aboutPanel = el(
|
||||
'div',
|
||||
{ class: 'ppanel' },
|
||||
FlavorText(species, vg),
|
||||
factsHolder,
|
||||
el('h3', { class: 'ppanel__sub' }, 'Breeding'),
|
||||
el(
|
||||
'dl',
|
||||
{ class: 'pfacts' },
|
||||
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)),
|
||||
species.genera?.length
|
||||
? fact(
|
||||
'Category',
|
||||
(species.genera.find((g) => g.language.name === 'en') || {}).genus || '—',
|
||||
)
|
||||
: null,
|
||||
el('div', { class: 'fact' }, el('dt', {}, 'Gender'), genderCell(species.gender_rate)),
|
||||
fact(
|
||||
'Egg groups',
|
||||
eggGroups.includes('Undiscovered') ? "Undiscovered (can't breed)" : eggGroups.join(', ') || '—',
|
||||
),
|
||||
fact(
|
||||
'Egg cycles',
|
||||
hatch != null ? `${hatch} (~${((hatch + 1) * 255).toLocaleString()} steps)` : '—',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// ---- Stats panel ---------------------------------------------
|
||||
const stat = (name) =>
|
||||
pokemon.stats.find((s) => s.stat.name === name)?.base_stat ?? 0;
|
||||
const statTotal = STAT_NAMES.reduce((sum, n) => sum + stat(n), 0);
|
||||
const statBars = STAT_NAMES.map((n) => StatBar(n, stat(n)));
|
||||
const statsPanel = el(
|
||||
'div',
|
||||
{ class: 'ppanel' },
|
||||
el('div', { class: 'pstats' }, ...statBars),
|
||||
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('h3', { class: 'ppanel__sub' }, 'Type matchups (defending)'),
|
||||
TypeMatchups(types, vgGen),
|
||||
);
|
||||
const statsPanel = el('div', { class: 'ppanel' }, ...statsContent(pk));
|
||||
|
||||
// ---- Evolution panel --------------------------------------
|
||||
const evoPanel = el('div', { class: 'ppanel' }, el('p', { class: 'detail__muted' }, 'Loading…'));
|
||||
loadEvolution(species, st, snap, vgGen).then((node) => evoPanel.replaceChildren(node));
|
||||
|
||||
const allGames = st.versionGroup === 'all';
|
||||
|
||||
// ---- Moves panel ----------------------------------------
|
||||
const movesPanel = el(
|
||||
'div',
|
||||
{ class: 'ppanel' },
|
||||
allGames
|
||||
? el('p', { class: 'detail__muted' }, 'Pick a game to see its learnset.')
|
||||
: MovesList(pokemon.moves, {
|
||||
versionGroupKey: st.versionGroup,
|
||||
gen: vgGen,
|
||||
genOfVg: (name) => snap.versionGroupByKey.get(name)?.generation ?? 9,
|
||||
}),
|
||||
);
|
||||
const movesPanel = el('div', { class: 'ppanel' }, movesContent(pk));
|
||||
|
||||
// ---- Locations panel ----------------------------------
|
||||
const locPanel = el('div', { class: 'ppanel' }, el('p', { class: 'detail__muted' }, 'Loading…'));
|
||||
@ -295,6 +449,55 @@ export async function PokemonDetail(nationalId) {
|
||||
? `#${String(regionalNumber).padStart(3, '0')} · ${prettify(dex.name || dex.key)}`
|
||||
: `#${String(nationalId).padStart(4, '0')} · National`;
|
||||
|
||||
const heroForm = el('span', { class: 'phero__form' });
|
||||
const heroName = el(
|
||||
'h1',
|
||||
{ class: 'phero__name' },
|
||||
el('span', {}, species.name.replace(/-/g, ' ')),
|
||||
heroForm,
|
||||
);
|
||||
const heroTypes = el('div', { class: 'phero__types' }, ...types.map(TypeChip));
|
||||
|
||||
// Form switcher: Base + each snapshot form. Selecting one re-fetches that
|
||||
// form's /pokemon and rebuilds the type-dependent parts of the page.
|
||||
let formBar = null;
|
||||
if (forms.length) {
|
||||
const opts = [{ slug: null, name: 'Base' }, ...forms];
|
||||
const pills = opts.map((o) =>
|
||||
el(
|
||||
'button',
|
||||
{
|
||||
class: 'formbar__pill',
|
||||
type: 'button',
|
||||
onclick: () => switchForm(o.slug ? o : null, pills, opts),
|
||||
},
|
||||
o.name,
|
||||
),
|
||||
);
|
||||
pills[0].classList.add('is-active');
|
||||
formBar = el('div', { class: 'formbar' }, ...pills);
|
||||
}
|
||||
|
||||
async function switchForm(form, pills, opts) {
|
||||
activeForm = form;
|
||||
pills.forEach((b, i) => b.classList.toggle('is-active', opts[i].slug === (form ? form.slug : null)));
|
||||
heroForm.textContent = form ? form.name : '';
|
||||
try {
|
||||
pk = form ? await getPokemon(form.slug) : pokemon;
|
||||
} catch {
|
||||
pk = pokemon;
|
||||
}
|
||||
types = typesForGeneration(pk, vgGen);
|
||||
mainType = types[0];
|
||||
applyThemeVars();
|
||||
paintArt();
|
||||
heroTypes.replaceChildren(...types.map(TypeChip));
|
||||
factsHolder.replaceChildren(formFactsNode(pk));
|
||||
statsPanel.replaceChildren(...statsContent(pk));
|
||||
movesPanel.replaceChildren(movesContent(pk));
|
||||
if (ui.get().detailTab === 'stats') animateStats();
|
||||
}
|
||||
|
||||
clear(view).append(
|
||||
el(
|
||||
'div',
|
||||
@ -307,14 +510,15 @@ export async function PokemonDetail(nationalId) {
|
||||
{ class: 'phero__top' },
|
||||
el('a', { class: 'phero__back', href: '#/' }, '‹ Dex'),
|
||||
el('span', { class: 'phero__num' }, numLabel),
|
||||
el('div', { class: 'phero__actions' }, shinyBtn, favBtn),
|
||||
el('div', { class: 'phero__actions' }, cryBtn, shinyBtn, favBtn),
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'phero__head' },
|
||||
el('h1', { class: 'phero__name' }, species.name.replace(/-/g, ' ')),
|
||||
el('div', { class: 'phero__types' }, ...types.map(TypeChip)),
|
||||
heroName,
|
||||
heroTypes,
|
||||
),
|
||||
formBar,
|
||||
el(
|
||||
'div',
|
||||
{ class: 'phero__stage' },
|
||||
@ -330,7 +534,7 @@ export async function PokemonDetail(nationalId) {
|
||||
el(
|
||||
'div',
|
||||
{ class: 'psheet' },
|
||||
el('div', { class: 'ptrack' }, seenBtn, caughtBtn),
|
||||
el('div', { class: 'ptrack' }, seenBtn, caughtBtn, teamBtn),
|
||||
el('div', { class: 'psheet__tabs', role: 'tablist' }, ...tabButtons),
|
||||
body,
|
||||
),
|
||||
@ -345,8 +549,10 @@ export async function PokemonDetail(nationalId) {
|
||||
});
|
||||
|
||||
const off = settings.subscribe(() => syncTrack());
|
||||
const offTeam = team.subscribe(syncTeamBtn);
|
||||
onTeardown(view, () => {
|
||||
off();
|
||||
offTeam();
|
||||
offSwipe();
|
||||
});
|
||||
return view;
|
||||
@ -372,3 +578,36 @@ async function loadEvolution(species, st, snap, vgGen) {
|
||||
function fact(label, value) {
|
||||
return el('div', { class: 'fact' }, el('dt', {}, label), el('dd', {}, value));
|
||||
}
|
||||
|
||||
/** STAB offensive coverage summary for the Stats tab. */
|
||||
function offensiveBlock(types, gen) {
|
||||
const { strong, walls } = offensiveSummary(types, gen);
|
||||
return el(
|
||||
'div',
|
||||
{},
|
||||
el('h3', { class: 'ppanel__sub' }, 'STAB coverage (attacking)'),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'matchups' },
|
||||
strong.length
|
||||
? el(
|
||||
'div',
|
||||
{ class: 'matchups__row matchups__row--x2' },
|
||||
el('span', { class: 'matchups__label' }, 'Strong vs'),
|
||||
el('span', { class: 'matchups__types' }, ...strong.map(TypeChip)),
|
||||
)
|
||||
: null,
|
||||
walls.length
|
||||
? el(
|
||||
'div',
|
||||
{ class: 'matchups__row matchups__row--half' },
|
||||
el('span', { class: 'matchups__label' }, 'Walled by'),
|
||||
el('span', { class: 'matchups__types' }, ...walls.map(TypeChip)),
|
||||
)
|
||||
: null,
|
||||
!strong.length && !walls.length
|
||||
? el('p', { class: 'detail__muted' }, 'No notable STAB coverage.')
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,31 +4,32 @@ import { settings } from '../store/settings.js';
|
||||
import { ui } from '../store/ui.js';
|
||||
import { entry } from '../store/selection.js';
|
||||
import { prettify } from '../data/pokedex-resolver.js';
|
||||
import { getMoveIndex, getItemIndex, getMove, mapLimit } from '../data/api.js';
|
||||
import { getMachine, mapLimit } from '../data/api.js';
|
||||
import { Sprite } from '../components/Sprite.js';
|
||||
import { TypeChip } from '../components/TypeChip.js';
|
||||
|
||||
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
|
||||
// Names in the API use hyphens; people type spaces. Compare both loosely.
|
||||
const loose = (s) => s.toLowerCase().replace(/[-\s]+/g, ' ').trim();
|
||||
const DMG = { physical: 'Phys', special: 'Spec', status: 'Stat' };
|
||||
const TYPES = [
|
||||
'normal', 'fire', 'water', 'electric', 'grass', 'ice', 'fighting', 'poison',
|
||||
'ground', 'flying', 'psychic', 'bug', 'rock', 'ghost', 'dragon', 'dark', 'steel', 'fairy',
|
||||
];
|
||||
const itemSprite = (name) =>
|
||||
`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/${name}.png`;
|
||||
const DMG = { physical: 'Phys', special: 'Spec', status: 'Stat' };
|
||||
|
||||
let moveIndex = null;
|
||||
let itemIndex = null;
|
||||
|
||||
const TABS = [
|
||||
{ id: 'pokemon', label: 'Pokémon' },
|
||||
{ id: 'moves', label: 'Moves' },
|
||||
{ id: 'items', label: 'Items' },
|
||||
{ id: 'abilities', label: 'Abilities' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Unified lookup for Pokémon, moves and items. Pokémon come from the bundled
|
||||
* snapshot (offline); moves and items lazy-load a name index from PokéAPI
|
||||
* (then cached by the service worker) and fetch per-entry detail on demand.
|
||||
*/
|
||||
const tmLabel = (raw) => {
|
||||
const m = raw.match(/^([a-z]+)0*(\d+)$/i);
|
||||
return m ? `${m[1].toUpperCase()}${String(Number(m[2])).padStart(2, '0')}` : raw.toUpperCase();
|
||||
};
|
||||
|
||||
export async function SearchView() {
|
||||
const snap = await loadSnapshot();
|
||||
const view = el('section', { class: 'view search' });
|
||||
@ -62,7 +63,7 @@ export async function SearchView() {
|
||||
tab = t.id;
|
||||
ui.set({ searchTab: t.id });
|
||||
[...seg.children].forEach((b, i) => b.classList.toggle('is-active', TABS[i].id === tab));
|
||||
syncPlaceholder();
|
||||
syncUI();
|
||||
run();
|
||||
},
|
||||
},
|
||||
@ -71,32 +72,112 @@ export async function SearchView() {
|
||||
),
|
||||
);
|
||||
|
||||
const filters = el('div', { class: 'lookup__filters' });
|
||||
const note = el('p', { class: 'search__note' });
|
||||
const results = el('div', { class: 'search__results' });
|
||||
|
||||
function syncPlaceholder() {
|
||||
// ---- filter controls (rebuilt per tab) ---------------------------
|
||||
function pill(label, options, value, onChange) {
|
||||
return el(
|
||||
'select',
|
||||
{ class: 'feed-sort__select', onchange: (e) => onChange(e.target.value) },
|
||||
...options.map(([v, t]) => el('option', { value: v, selected: v === value }, t)),
|
||||
);
|
||||
}
|
||||
|
||||
function buildFilters() {
|
||||
clear(filters);
|
||||
if (tab === 'moves') {
|
||||
filters.append(
|
||||
pill('mvType', [['', 'Any type'], ...TYPES.map((t) => [t, prettify(t)])], ui.get().mvType, (v) => {
|
||||
ui.set({ mvType: v });
|
||||
run();
|
||||
}),
|
||||
pill(
|
||||
'mvClass',
|
||||
[['', 'Any category'], ['physical', 'Physical'], ['special', 'Special'], ['status', 'Status']],
|
||||
ui.get().mvClass,
|
||||
(v) => {
|
||||
ui.set({ mvClass: v });
|
||||
run();
|
||||
},
|
||||
),
|
||||
pill(
|
||||
'mvSort',
|
||||
[['name', 'A–Z'], ['power', 'Power'], ['accuracy', 'Accuracy'], ['gen', 'Newest']],
|
||||
ui.get().mvSort,
|
||||
(v) => {
|
||||
ui.set({ mvSort: v });
|
||||
run();
|
||||
},
|
||||
),
|
||||
el(
|
||||
'label',
|
||||
{ class: 'lookup__check' },
|
||||
el('input', {
|
||||
type: 'checkbox',
|
||||
checked: !!ui.get().mvTm,
|
||||
onchange: (e) => {
|
||||
ui.set({ mvTm: e.target.checked });
|
||||
run();
|
||||
},
|
||||
}),
|
||||
'TMs only',
|
||||
),
|
||||
);
|
||||
} else if (tab === 'items') {
|
||||
filters.append(
|
||||
pill(
|
||||
'itCat',
|
||||
[['', 'Any category'], ...snap.itemCategories.map((c) => [c, prettify(c)])],
|
||||
ui.get().itCat,
|
||||
(v) => {
|
||||
ui.set({ itCat: v });
|
||||
run();
|
||||
},
|
||||
),
|
||||
pill('itSort', [['name', 'A–Z'], ['id', 'Dex order']], ui.get().itSort, (v) => {
|
||||
ui.set({ itSort: v });
|
||||
run();
|
||||
}),
|
||||
);
|
||||
} else if (tab === 'abilities') {
|
||||
filters.append(
|
||||
pill(
|
||||
'abSort',
|
||||
[['name', 'A–Z'], ['count', 'Most Pokémon'], ['gen', 'Newest']],
|
||||
ui.get().abSort,
|
||||
(v) => {
|
||||
ui.set({ abSort: v });
|
||||
run();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function syncUI() {
|
||||
input.placeholder =
|
||||
tab === 'pokemon' ? 'Name, number or type…' : tab === 'moves' ? 'Move name…' : 'Item name…';
|
||||
const st = settings.get();
|
||||
note.textContent =
|
||||
tab === 'pokemon'
|
||||
? `Every Pokémon, any game. Opening one shows it for ${
|
||||
st.versionGroup === 'all' ? 'all games' : prettify(st.versionGroup)
|
||||
}.`
|
||||
? 'Name, number or type…'
|
||||
: tab === 'moves'
|
||||
? 'Every move in the series.'
|
||||
: 'Every item in the series.';
|
||||
? 'Move name…'
|
||||
: tab === 'items'
|
||||
? 'Item name…'
|
||||
: 'Ability name or effect…';
|
||||
buildFilters();
|
||||
}
|
||||
|
||||
view.append(
|
||||
el('header', { class: 'view__header' }, el('h1', {}, 'Search'), note),
|
||||
seg,
|
||||
filters,
|
||||
input,
|
||||
results,
|
||||
);
|
||||
syncPlaceholder();
|
||||
syncUI();
|
||||
|
||||
// ---- renderers -------------------------------------------------
|
||||
// ---- Pokémon -------------------------------------------------
|
||||
function renderPokemon(q) {
|
||||
const style = settings.get().spriteStyle;
|
||||
const matches = snap.species
|
||||
@ -123,85 +204,127 @@ export async function SearchView() {
|
||||
}
|
||||
}
|
||||
|
||||
async function renderMoves(q, token) {
|
||||
if (!moveIndex) {
|
||||
results.append(el('p', { class: 'search__empty' }, 'Loading moves…'));
|
||||
try {
|
||||
moveIndex = (await getMoveIndex()).results;
|
||||
} catch {
|
||||
if (token === runToken) results.textContent = 'Move list unavailable offline.';
|
||||
return;
|
||||
}
|
||||
if (token !== runToken) return; // superseded by a newer query/tab
|
||||
clear(results);
|
||||
}
|
||||
const matches = moveIndex.filter((m) => loose(m.name).includes(q)).slice(0, 80);
|
||||
if (!matches.length) return void results.append(el('p', { class: 'search__empty' }, 'No matches.'));
|
||||
// ---- Moves (from snapshot, offline) -----------------------------
|
||||
function renderMoves(q, token) {
|
||||
const st = settings.get();
|
||||
const vg = st.versionGroup;
|
||||
const type = ui.get().mvType;
|
||||
const cls = ui.get().mvClass;
|
||||
const sort = ui.get().mvSort;
|
||||
const tmOnly = !!ui.get().mvTm;
|
||||
|
||||
const metas = new Map();
|
||||
for (const m of matches) {
|
||||
const meta = el('span', { class: 'search__types' });
|
||||
metas.set(m.name, meta);
|
||||
let list = snap.moves.filter((m) => {
|
||||
if (q && !loose(m.name).includes(q)) return false;
|
||||
if (type && m.type !== type) return false;
|
||||
if (cls && m.damageClass !== cls) return false;
|
||||
if (tmOnly && !m.machines.some((x) => vg === 'all' || x.vg === vg)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
list.sort((a, b) => {
|
||||
if (sort === 'power') return (b.power ?? -1) - (a.power ?? -1) || a.name.localeCompare(b.name);
|
||||
if (sort === 'accuracy') return (b.accuracy ?? -1) - (a.accuracy ?? -1) || a.name.localeCompare(b.name);
|
||||
if (sort === 'gen') return b.generation - a.generation || a.name.localeCompare(b.name);
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
note.textContent = `${list.length} move${list.length === 1 ? '' : 's'}`;
|
||||
const shown = list.slice(0, 400);
|
||||
const machineCells = new Map();
|
||||
|
||||
for (const m of shown) {
|
||||
const tmCell = tmOnly ? el('span', { class: 'moverow__tm' }) : null;
|
||||
if (tmCell) machineCells.set(m.id, { cell: tmCell, m });
|
||||
results.append(
|
||||
el(
|
||||
'a',
|
||||
{ class: 'search__row search__row--slim', href: `#/move/${idFromUrl(m.url)}` },
|
||||
{ class: 'search__row search__row--slim', href: `#/move/${m.id}` },
|
||||
tmCell,
|
||||
el('span', { class: 'search__name' }, m.name.replace(/-/g, ' ')),
|
||||
meta,
|
||||
el(
|
||||
'span',
|
||||
{ class: 'search__types' },
|
||||
TypeChip(m.type),
|
||||
el('span', { class: 'moverow__cat', dataset: { cat: m.damageClass || '' } }, DMG[m.damageClass] || '—'),
|
||||
el('span', { class: 'search__num' }, m.power != null ? `${m.power} pw` : ''),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
// Enrich a short result set with type / class / power after a pause.
|
||||
clearTimeout(enrichTimer);
|
||||
if (matches.length <= 24) {
|
||||
if (list.length > shown.length) {
|
||||
results.append(el('p', { class: 'search__empty' }, `Showing ${shown.length} of ${list.length} — refine to see more.`));
|
||||
}
|
||||
|
||||
if (tmOnly && machineCells.size) {
|
||||
clearTimeout(enrichTimer);
|
||||
enrichTimer = setTimeout(() => {
|
||||
mapLimit(matches, 6, async (m) => {
|
||||
mapLimit([...machineCells.values()], 6, async ({ cell, m }) => {
|
||||
if (token !== runToken || !cell.isConnected) return;
|
||||
const hit = m.machines.find((x) => x.vg === vg) || m.machines[m.machines.length - 1];
|
||||
if (!hit) return;
|
||||
try {
|
||||
const d = await getMove(idFromUrl(m.url));
|
||||
const meta = metas.get(m.name);
|
||||
if (!meta.isConnected) return;
|
||||
meta.append(
|
||||
TypeChip(d.type.name),
|
||||
el('span', { class: 'moverow__cat', dataset: { cat: d.damage_class?.name || '' } },
|
||||
DMG[d.damage_class?.name] || '—'),
|
||||
el('span', { class: 'search__num' }, d.power != null ? `${d.power} pw` : ''),
|
||||
);
|
||||
const mc = await getMachine(hit.id);
|
||||
cell.textContent = tmLabel(mc.item.name);
|
||||
} catch {
|
||||
/* leave bare */
|
||||
/* leave blank */
|
||||
}
|
||||
});
|
||||
}, 220);
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderItems(q, token) {
|
||||
if (!itemIndex) {
|
||||
results.append(el('p', { class: 'search__empty' }, 'Loading items…'));
|
||||
try {
|
||||
itemIndex = (await getItemIndex()).results;
|
||||
} catch {
|
||||
if (token === runToken) results.textContent = 'Item list unavailable offline.';
|
||||
return;
|
||||
}
|
||||
if (token !== runToken) return;
|
||||
clear(results);
|
||||
}
|
||||
const matches = itemIndex.filter((it) => loose(it.name).includes(q)).slice(0, 80);
|
||||
if (!matches.length) return void results.append(el('p', { class: 'search__empty' }, 'No matches.'));
|
||||
for (const it of matches) {
|
||||
const icon = el('img', {
|
||||
class: 'search__item-icon',
|
||||
loading: 'lazy',
|
||||
alt: '',
|
||||
src: itemSprite(it.name),
|
||||
});
|
||||
// ---- Items (from snapshot, offline) ---------------------------
|
||||
function renderItems(q) {
|
||||
const cat = ui.get().itCat;
|
||||
const sort = ui.get().itSort;
|
||||
let list = snap.items.filter((it) => {
|
||||
if (q && !loose(it.name).includes(q)) return false;
|
||||
if (cat && it.category !== cat) return false;
|
||||
return true;
|
||||
});
|
||||
list.sort((a, b) => (sort === 'id' ? a.id - b.id : a.name.localeCompare(b.name)));
|
||||
|
||||
note.textContent = `${list.length} item${list.length === 1 ? '' : 's'}`;
|
||||
const shown = list.slice(0, 400);
|
||||
for (const it of shown) {
|
||||
const icon = el('img', { class: 'search__item-icon', loading: 'lazy', alt: '', src: itemSprite(it.name) });
|
||||
icon.addEventListener('error', () => icon.remove(), { once: true });
|
||||
results.append(
|
||||
el(
|
||||
'a',
|
||||
{ class: 'search__row', href: `#/item/${idFromUrl(it.url)}` },
|
||||
{ class: 'search__row', href: `#/item/${it.id}` },
|
||||
icon,
|
||||
el('span', { class: 'search__name' }, it.name.replace(/-/g, ' ')),
|
||||
el('span', { class: 'search__cat' }, prettify(it.category)),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (list.length > shown.length) {
|
||||
results.append(el('p', { class: 'search__empty' }, `Showing ${shown.length} of ${list.length} — refine to see more.`));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Abilities (from snapshot, offline) ---------------------
|
||||
function renderAbilities(q) {
|
||||
const sort = ui.get().abSort;
|
||||
let list = snap.abilities.filter(
|
||||
(a) => !q || loose(a.name).includes(q) || a.effect.toLowerCase().includes(q),
|
||||
);
|
||||
list.sort((a, b) => {
|
||||
if (sort === 'count') return b.pokemon.length - a.pokemon.length || a.name.localeCompare(b.name);
|
||||
if (sort === 'gen') return b.generation - a.generation || a.name.localeCompare(b.name);
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
note.textContent = `${list.length} abilit${list.length === 1 ? 'y' : 'ies'}`;
|
||||
for (const a of list.slice(0, 400)) {
|
||||
results.append(
|
||||
el(
|
||||
'a',
|
||||
{ class: 'search__row search__row--ability', href: `#/ability/${a.id}` },
|
||||
el('span', { class: 'search__name' }, a.name.replace(/-/g, ' ')),
|
||||
el('span', { class: 'search__cat' }, `Gen ${a.generation}`),
|
||||
el('span', { class: 'search__cat' }, `${a.pokemon.length} 🐾`),
|
||||
el('span', { class: 'search__ab-eff' }, a.effect || '—'),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -211,12 +334,18 @@ export async function SearchView() {
|
||||
const token = ++runToken;
|
||||
const q = loose(query);
|
||||
clear(results);
|
||||
note.textContent = '';
|
||||
if (tab === 'pokemon') {
|
||||
const st = settings.get();
|
||||
note.textContent = `Every Pokémon, any game. Opens for ${
|
||||
st.versionGroup === 'all' ? 'all games' : prettify(st.versionGroup)
|
||||
}.`;
|
||||
if (q) renderPokemon(q);
|
||||
return;
|
||||
}
|
||||
if (!q) return;
|
||||
(tab === 'moves' ? renderMoves : renderItems)(q, token);
|
||||
if (tab === 'moves') renderMoves(q, token);
|
||||
else if (tab === 'items') renderItems(q);
|
||||
else renderAbilities(q);
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
@ -63,6 +63,15 @@ export async function SettingsView() {
|
||||
'Default detail view to shiny sprites',
|
||||
);
|
||||
|
||||
const teamNavField = el('label', { class: 'field field--check' },
|
||||
el('input', {
|
||||
type: 'checkbox',
|
||||
checked: st.showTeamNav !== false,
|
||||
onchange: (e) => settings.set({ showTeamNav: e.target.checked }),
|
||||
}),
|
||||
'Show Team in the navigation bar',
|
||||
);
|
||||
|
||||
const storageNote = el('p', { class: 'settings__note' }, 'Calculating storage…');
|
||||
if (navigator.storage?.estimate) {
|
||||
navigator.storage.estimate().then(({ usage = 0, quota = 0 }) => {
|
||||
@ -93,7 +102,7 @@ export async function SettingsView() {
|
||||
|
||||
view.append(
|
||||
el('header', { class: 'view__header' }, el('h1', {}, 'Settings')),
|
||||
el('div', { class: 'settings__group' }, themeField, accentField, spriteField, shinyField),
|
||||
el('div', { class: 'settings__group' }, themeField, accentField, spriteField, shinyField, teamNavField),
|
||||
|
||||
el('h2', {}, 'Your data'),
|
||||
storageNote,
|
||||
|
||||
283
src/views/TeamView.js
Normal file
283
src/views/TeamView.js
Normal file
@ -0,0 +1,283 @@
|
||||
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));
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user