Compare commits
No commits in common. "f9f2b8f1e806e0f5248af1b6a6def2f40f697583" and "8ebdb9d19702cfe4dae73e40dddd2017b9af9ae1" have entirely different histories.
f9f2b8f1e8
...
8ebdb9d197
14
README.md
14
README.md
@ -71,10 +71,6 @@ Working app with a type-themed UI:
|
|||||||
|
|
||||||
- **Dex grid** — type-tinted cards (official artwork, spotlight, ghost number),
|
- **Dex grid** — type-tinted cards (official artwork, spotlight, ghost number),
|
||||||
per-dex progress, name/number filter, caught/favorite filters.
|
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 /
|
- **Detail** — type-gradient hero + tabbed sheet (About / Stats / Evolution /
|
||||||
Moves / Locations); coloured animated stat bars; defensive type matchups;
|
Moves / Locations); coloured animated stat bars; defensive type matchups;
|
||||||
evolution chain re-parented to the selected game's generation; learnset with
|
evolution chain re-parented to the selected game's generation; learnset with
|
||||||
@ -84,13 +80,9 @@ Working app with a type-themed UI:
|
|||||||
applies Gen 1 / pre-Gen 6 rules.
|
applies Gen 1 / pre-Gen 6 rules.
|
||||||
- **Games** — overlay picker with stylised version-colour cover tiles,
|
- **Games** — overlay picker with stylised version-colour cover tiles,
|
||||||
sub-dex switch, and an "All games" (National, no gen limits) option.
|
sub-dex switch, and an "All games" (National, no gen limits) option.
|
||||||
- **Search** — tabbed lookup: Pokémon, Moves and Items, all browsable
|
- **Search** — tabbed lookup: Pokémon (offline, from the snapshot), Moves
|
||||||
offline from the snapshot. Moves filter by type / damage class / "TMs in
|
and Items (lazy name index + on-demand detail, SW-cached), each with its
|
||||||
this game" and sort by power / accuracy / recency; Items filter by
|
own detail page. Query, scroll and tab persist.
|
||||||
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
|
- **Settings** — theme (System / Light / Dark / Black / Sepia) + accent
|
||||||
colour, sprite style, JSON export/import.
|
colour, sprite style, JSON export/import.
|
||||||
- **PWA** — Workbox SW: precache shell + snapshot, SWR for API JSON,
|
- **PWA** — Workbox SW: precache shell + snapshot, SWR for API JSON,
|
||||||
|
|||||||
@ -30,34 +30,6 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|||||||
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
|
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
|
||||||
const pretty = (k) => k.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
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();
|
const _cache = new Map();
|
||||||
async function api(path) {
|
async function api(path) {
|
||||||
if (_cache.has(path)) return _cache.get(path);
|
if (_cache.has(path)) return _cache.get(path);
|
||||||
@ -180,9 +152,14 @@ async function main() {
|
|||||||
.map((r) => idFromUrl(r.url))
|
.map((r) => idFromUrl(r.url))
|
||||||
.sort((a, b) => a - b);
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
const statMap = (pk) => {
|
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 s = Object.fromEntries(pk.stats.map((x) => [x.stat.name, x.base_stat]));
|
const s = Object.fromEntries(pk.stats.map((x) => [x.stat.name, x.base_stat]));
|
||||||
return {
|
const stats = {
|
||||||
hp: s.hp ?? 0,
|
hp: s.hp ?? 0,
|
||||||
atk: s.attack ?? 0,
|
atk: s.attack ?? 0,
|
||||||
def: s.defense ?? 0,
|
def: s.defense ?? 0,
|
||||||
@ -190,61 +167,7 @@ async function main() {
|
|||||||
spd: s['special-defense'] ?? 0,
|
spd: s['special-defense'] ?? 0,
|
||||||
spe: s.speed ?? 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 {
|
return {
|
||||||
forms,
|
|
||||||
id,
|
id,
|
||||||
name: sp.name,
|
name: sp.name,
|
||||||
generation: idFromUrl(sp.generation.url),
|
generation: idFromUrl(sp.generation.url),
|
||||||
@ -273,66 +196,6 @@ 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 ----------------------------------------------------
|
// ---- Write ----------------------------------------------------
|
||||||
const snapshot = {
|
const snapshot = {
|
||||||
meta: {
|
meta: {
|
||||||
@ -340,9 +203,6 @@ async function main() {
|
|||||||
source: BASE,
|
source: BASE,
|
||||||
counts: {
|
counts: {
|
||||||
species: species.length,
|
species: species.length,
|
||||||
moves: moves.length,
|
|
||||||
items: items.length,
|
|
||||||
abilities: abilities.length,
|
|
||||||
pokedexes: pokedexes.length,
|
pokedexes: pokedexes.length,
|
||||||
versionGroups: versionGroups.length,
|
versionGroups: versionGroups.length,
|
||||||
},
|
},
|
||||||
@ -350,9 +210,6 @@ async function main() {
|
|||||||
types,
|
types,
|
||||||
generations,
|
generations,
|
||||||
species,
|
species,
|
||||||
moves,
|
|
||||||
items,
|
|
||||||
abilities,
|
|
||||||
pokedexes,
|
pokedexes,
|
||||||
versionGroups,
|
versionGroups,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,17 +3,15 @@ import { Sprite } from './Sprite.js';
|
|||||||
import { TypeChip } from './TypeChip.js';
|
import { TypeChip } from './TypeChip.js';
|
||||||
import { entry, toggle } from '../store/selection.js';
|
import { entry, toggle } from '../store/selection.js';
|
||||||
import { typeHex } from '../lib/type-color.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
|
* 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
|
* 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.
|
* above the card, a number chip, and a big ghost number in the corner.
|
||||||
*/
|
*/
|
||||||
export function Card(species, number, { spriteStyle = 'official', versionGroup, gen = 9, boxed = false, metric = null } = {}) {
|
export function Card(species, number, { spriteStyle = 'official', versionGroup, boxed = false, metric = null } = {}) {
|
||||||
const state = entry(species.id);
|
const state = entry(species.id);
|
||||||
const types = typesForGen(species, gen);
|
const mainType = (species.types || [])[0] || 'normal';
|
||||||
const mainType = types[0] || 'normal';
|
|
||||||
const num = String(number ?? species.id).padStart(3, '0');
|
const num = String(number ?? species.id).padStart(3, '0');
|
||||||
const data = { type: mainType, sprite: spriteStyle };
|
const data = { type: mainType, sprite: spriteStyle };
|
||||||
if (boxed) data.boxed = '1';
|
if (boxed) data.boxed = '1';
|
||||||
@ -60,10 +58,7 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup,
|
|||||||
{ class: 'card__body' },
|
{ class: 'card__body' },
|
||||||
el('span', { class: 'card__num' }, `#${num}`),
|
el('span', { class: 'card__num' }, `#${num}`),
|
||||||
el('span', { class: 'card__name' }, species.name.replace(/-/g, ' ')),
|
el('span', { class: 'card__name' }, species.name.replace(/-/g, ' ')),
|
||||||
el('span', { class: 'card__types' }, ...types.map(TypeChip)),
|
el('span', { class: 'card__types' }, ...(species.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,
|
metric ? el('span', { class: 'card__metric' }, metric) : null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
import { el } from '../lib/dom.js';
|
import { el } from '../lib/dom.js';
|
||||||
import { openGameSheet } from './GameSheet.js';
|
import { openGameSheet } from './GameSheet.js';
|
||||||
import { settings } from '../store/settings.js';
|
|
||||||
|
|
||||||
const ITEMS = [
|
const ITEMS = [
|
||||||
{ label: 'Dex', icon: '▦', href: '#/', match: (h) => h === '#/' || h === '' || h === '#' },
|
{ 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: 'Games', icon: '◉', action: openGameSheet },
|
||||||
{ label: 'Search', icon: '⌕', href: '#/search', match: (h) => h.startsWith('#/search') },
|
{ label: 'Search', icon: '⌕', href: '#/search', match: (h) => h.startsWith('#/search') },
|
||||||
{ label: 'Settings', icon: '⚙', href: '#/settings', match: (h) => h.startsWith('#/settings') },
|
{ label: 'Settings', icon: '⚙', href: '#/settings', match: (h) => h.startsWith('#/settings') },
|
||||||
@ -12,47 +10,41 @@ const ITEMS = [
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One nav element, styled by CSS into a bottom bar on phones and a sidebar
|
* One nav element, styled by CSS into a bottom bar on phones and a sidebar
|
||||||
* on wide screens. The Games item summons an overlay; optional items (Team)
|
* on wide screens. Links derive their active state from the hash; the Games
|
||||||
* can be hidden from Settings.
|
* item summons an overlay instead of navigating.
|
||||||
*/
|
*/
|
||||||
export function Nav() {
|
export function Nav() {
|
||||||
const nav = el('nav', { class: 'nav', 'aria-label': 'Primary' });
|
const links = ITEMS.map((item) => {
|
||||||
let links = [];
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
function build() {
|
const nav = el(
|
||||||
const st = settings.get();
|
'nav',
|
||||||
const items = ITEMS.filter((it) => !it.optional || st[it.optional]);
|
{ class: 'nav', 'aria-label': 'Primary' },
|
||||||
links = items.map((item) => {
|
el('span', { class: 'nav__brand' }, 'Pokédex'),
|
||||||
const inner = [
|
...links,
|
||||||
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() {
|
function sync() {
|
||||||
const hash = location.hash || '#/';
|
const hash = location.hash || '#/';
|
||||||
(nav._items || []).forEach((item, i) => {
|
ITEMS.forEach((item, i) => {
|
||||||
if (!item.match) return;
|
if (!item.match) return;
|
||||||
links[i].classList.toggle('is-active', item.match(hash));
|
links[i].classList.toggle('is-active', item.match(hash));
|
||||||
links[i].setAttribute('aria-current', item.match(hash) ? 'page' : 'false');
|
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);
|
window.addEventListener('hashchange', sync);
|
||||||
|
sync();
|
||||||
return nav;
|
return nav;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,98 +0,0 @@
|
|||||||
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'));
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,13 +20,6 @@ export async function loadSnapshot() {
|
|||||||
data.pokedexByKey = new Map(data.pokedexes.map((d) => [d.key, d]));
|
data.pokedexByKey = new Map(data.pokedexes.map((d) => [d.key, d]));
|
||||||
data.versionGroupByKey = new Map(data.versionGroups.map((v) => [v.key, v]));
|
data.versionGroupByKey = new Map(data.versionGroups.map((v) => [v.key, v]));
|
||||||
data.generationById = new Map(data.generations.map((g) => [g.id, g]));
|
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;
|
cached = data;
|
||||||
return cached;
|
return cached;
|
||||||
|
|||||||
@ -64,31 +64,6 @@ export function multiplier(attacking, defTypes, gen = 9) {
|
|||||||
return m;
|
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.
|
* Defensive matchups for a Pokémon, bucketed by multiplier.
|
||||||
* Returns { '4': [...types], '2': [...], '0.5': [...], '0.25': [...], '0': [...] }
|
* Returns { '4': [...types], '2': [...], '0.5': [...], '0.25': [...], '0': [...] }
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,9 +5,6 @@ import { SearchView } from './views/SearchView.js';
|
|||||||
import { SettingsView } from './views/SettingsView.js';
|
import { SettingsView } from './views/SettingsView.js';
|
||||||
import { MoveDetail } from './views/MoveDetail.js';
|
import { MoveDetail } from './views/MoveDetail.js';
|
||||||
import { ItemDetail } from './views/ItemDetail.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';
|
import { detailSkeleton, lookupSkeleton } from './components/skeletons.js';
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
@ -15,9 +12,6 @@ const routes = [
|
|||||||
{ pattern: /^#\/pokemon\/(\d+)$/, view: (m) => PokemonDetail(Number(m[1])), skeleton: detailSkeleton },
|
{ pattern: /^#\/pokemon\/(\d+)$/, view: (m) => PokemonDetail(Number(m[1])), skeleton: detailSkeleton },
|
||||||
{ pattern: /^#\/move\/(\d+)$/, view: (m) => MoveDetail(Number(m[1])), skeleton: lookupSkeleton },
|
{ pattern: /^#\/move\/(\d+)$/, view: (m) => MoveDetail(Number(m[1])), skeleton: lookupSkeleton },
|
||||||
{ pattern: /^#\/item\/(\d+)$/, view: (m) => ItemDetail(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: /^#\/search$/, view: () => SearchView() },
|
||||||
{ pattern: /^#\/settings$/, view: () => SettingsView() },
|
{ pattern: /^#\/settings$/, view: () => SettingsView() },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -17,7 +17,6 @@ export const settings = createStore('pdx.settings', {
|
|||||||
accent: 'red', // red | blue | green | amber | violet | rose
|
accent: 'red', // red | blue | green | amber | violet | rose
|
||||||
spriteStyle: 'official',
|
spriteStyle: 'official',
|
||||||
showShiny: false,
|
showShiny: false,
|
||||||
showTeamNav: true,
|
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
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,16 +13,4 @@ export const ui = createStore('pdx.ui', {
|
|||||||
sort: 'dex',
|
sort: 'dex',
|
||||||
sortDesc: false,
|
sortDesc: false,
|
||||||
filter: 'all',
|
filter: 'all',
|
||||||
filterType: '',
|
|
||||||
filterGen: 0,
|
|
||||||
recent: [],
|
|
||||||
toolsOpen: false,
|
|
||||||
teamMode: 'coverage',
|
|
||||||
mvType: '',
|
|
||||||
mvClass: '',
|
|
||||||
mvSort: 'name',
|
|
||||||
mvTm: false,
|
|
||||||
itCat: '',
|
|
||||||
itSort: 'name',
|
|
||||||
abSort: 'name',
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1825,608 +1825,3 @@
|
|||||||
animation: none;
|
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(
|
registerRoute(
|
||||||
({ url }) =>
|
({ url }) =>
|
||||||
url.origin === 'https://raw.githubusercontent.com' &&
|
url.origin === 'https://raw.githubusercontent.com' &&
|
||||||
(url.pathname.includes('/PokeAPI/sprites/') || url.pathname.includes('/PokeAPI/cries/')),
|
url.pathname.includes('/PokeAPI/sprites/'),
|
||||||
new CacheFirst({
|
new CacheFirst({
|
||||||
cacheName: 'pokeapi-img',
|
cacheName: 'pokeapi-img',
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|||||||
@ -1,66 +0,0 @@
|
|||||||
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,13 +6,6 @@ import { ui } from '../store/ui.js';
|
|||||||
import { selection, stats } from '../store/selection.js';
|
import { selection, stats } from '../store/selection.js';
|
||||||
import { Card } from '../components/Card.js';
|
import { Card } from '../components/Card.js';
|
||||||
import { ProgressRing } from '../components/ProgressRing.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 = [
|
const FILTERS = [
|
||||||
{ key: 'all', label: 'All', test: () => true },
|
{ key: 'all', label: 'All', test: () => true },
|
||||||
@ -80,13 +73,10 @@ export async function DexGrid() {
|
|||||||
const grid = el('div', { class: 'grid' });
|
const grid = el('div', { class: 'grid' });
|
||||||
let query = '';
|
let query = '';
|
||||||
let filterKey = ui.get().filter || 'all';
|
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 sortKey = ui.get().sort || 'dex';
|
||||||
let sortDesc = !!ui.get().sortDesc;
|
let sortDesc = !!ui.get().sortDesc;
|
||||||
let rows = [];
|
let rows = [];
|
||||||
let ids = [];
|
let ids = [];
|
||||||
let lastList = [];
|
|
||||||
|
|
||||||
const ring = ProgressRing();
|
const ring = ProgressRing();
|
||||||
|
|
||||||
@ -119,7 +109,6 @@ export async function DexGrid() {
|
|||||||
chipEls.forEach((c, i) => c.btn.classList.toggle('is-active', FILTERS[i].key === f.key));
|
chipEls.forEach((c, i) => c.btn.classList.toggle('is-active', FILTERS[i].key === f.key));
|
||||||
paintGrid();
|
paintGrid();
|
||||||
resetScroll();
|
resetScroll();
|
||||||
syncToolsBadge();
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
el('span', {}, f.label),
|
el('span', {}, f.label),
|
||||||
@ -162,54 +151,6 @@ export async function DexGrid() {
|
|||||||
}
|
}
|
||||||
syncDirBtn();
|
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.
|
// Changing the list order/contents makes the old scroll offset meaningless.
|
||||||
function resetScroll() {
|
function resetScroll() {
|
||||||
ui.set({ feedScroll: 0 });
|
ui.set({ feedScroll: 0 });
|
||||||
@ -234,58 +175,6 @@ export async function DexGrid() {
|
|||||||
if (document.fonts && document.fonts.ready) document.fonts.ready.then(go);
|
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(
|
view.append(
|
||||||
el(
|
el(
|
||||||
'header',
|
'header',
|
||||||
@ -302,10 +191,15 @@ export async function DexGrid() {
|
|||||||
el('span', { class: 'field-search__icon', html: SEARCH_ICON, 'aria-hidden': 'true' }),
|
el('span', { class: 'field-search__icon', html: SEARCH_ICON, 'aria-hidden': 'true' }),
|
||||||
search,
|
search,
|
||||||
),
|
),
|
||||||
toolsToggle,
|
filterBar,
|
||||||
tools,
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'feed-sort' },
|
||||||
|
el('span', { class: 'feed-sort__label' }, 'Sort'),
|
||||||
|
sortSelect,
|
||||||
|
dirBtn,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
recentStrip,
|
|
||||||
grid,
|
grid,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -334,8 +228,9 @@ export async function DexGrid() {
|
|||||||
function paintGrid() {
|
function paintGrid() {
|
||||||
const st = settings.get();
|
const st = settings.get();
|
||||||
const pokemonState = selection.get().pokemon;
|
const pokemonState = selection.get().pokemon;
|
||||||
const gen = snap.versionGroupByKey.get(st.versionGroup)?.generation ?? 9;
|
const boxed =
|
||||||
const boxed = st.spriteStyle === 'game' && gen <= 2;
|
st.spriteStyle === 'game' &&
|
||||||
|
(snap.versionGroupByKey.get(st.versionGroup)?.generation ?? 9) <= 2;
|
||||||
const filterTest = FILTERS.find((f) => f.key === filterKey).test;
|
const filterTest = FILTERS.find((f) => f.key === filterKey).test;
|
||||||
|
|
||||||
let list = rows.filter(({ species, number }) => {
|
let list = rows.filter(({ species, number }) => {
|
||||||
@ -347,8 +242,6 @@ export async function DexGrid() {
|
|||||||
) {
|
) {
|
||||||
return false;
|
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 };
|
const e = pokemonState[species.id] || { seen: false, caught: false, favorite: false };
|
||||||
return filterTest(e, species);
|
return filterTest(e, species);
|
||||||
});
|
});
|
||||||
@ -362,14 +255,12 @@ export async function DexGrid() {
|
|||||||
return (a.row.number ?? a.row.species.id) - (b.row.number ?? b.row.species.id);
|
return (a.row.number ?? a.row.species.id) - (b.row.number ?? b.row.species.id);
|
||||||
})
|
})
|
||||||
.map((x) => x.row);
|
.map((x) => x.row);
|
||||||
lastList = list;
|
|
||||||
|
|
||||||
const frag = document.createDocumentFragment();
|
const frag = document.createDocumentFragment();
|
||||||
list.forEach(({ species, number }, i) => {
|
list.forEach(({ species, number }, i) => {
|
||||||
const card = Card(species, number, {
|
const card = Card(species, number, {
|
||||||
spriteStyle: st.spriteStyle,
|
spriteStyle: st.spriteStyle,
|
||||||
versionGroup: st.versionGroup,
|
versionGroup: st.versionGroup,
|
||||||
gen,
|
|
||||||
boxed,
|
boxed,
|
||||||
metric: metricLabel(sortKey, species),
|
metric: metricLabel(sortKey, species),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,36 +0,0 @@
|
|||||||
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,7 +6,6 @@ import { getPokemon, getSpecies, getEvolutionChain, getEncounters } from '../dat
|
|||||||
import { settings } from '../store/settings.js';
|
import { settings } from '../store/settings.js';
|
||||||
import { ui } from '../store/ui.js';
|
import { ui } from '../store/ui.js';
|
||||||
import { entry, toggle } from '../store/selection.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 { Sprite } from '../components/Sprite.js';
|
||||||
import { TypeChip } from '../components/TypeChip.js';
|
import { TypeChip } from '../components/TypeChip.js';
|
||||||
import { StatBar } from '../components/StatBar.js';
|
import { StatBar } from '../components/StatBar.js';
|
||||||
@ -16,47 +15,9 @@ import { TypeMatchups } from '../components/TypeMatchups.js';
|
|||||||
import { Locations } from '../components/Locations.js';
|
import { Locations } from '../components/Locations.js';
|
||||||
import { FlavorText } from '../components/FlavorText.js';
|
import { FlavorText } from '../components/FlavorText.js';
|
||||||
import { typeHex } from '../lib/type-color.js';
|
import { typeHex } from '../lib/type-color.js';
|
||||||
import { offensiveSummary } from '../data/type-chart.js';
|
|
||||||
|
|
||||||
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
|
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. */
|
/** A Pokémon's typing as it was in the selected game's generation. */
|
||||||
function typesForGeneration(pokemon, gen) {
|
function typesForGeneration(pokemon, gen) {
|
||||||
const past = (pokemon.past_types || [])
|
const past = (pokemon.past_types || [])
|
||||||
@ -120,26 +81,11 @@ export async function PokemonDetail(nationalId) {
|
|||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recently-viewed history for the feed strip (most recent first, capped).
|
const types = typesForGeneration(pokemon, vgGen);
|
||||||
ui.set((s) => ({
|
const mainType = types[0];
|
||||||
recent: [nationalId, ...(s.recent || []).filter((x) => x !== nationalId)].slice(0, 12),
|
view.style.setProperty('--type-main', typeHex(mainType));
|
||||||
}));
|
view.style.setProperty('--type-2', typeHex(types[1] || mainType));
|
||||||
|
view.dataset.type = mainType;
|
||||||
// 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 --------------------------------------------
|
// ---- Artwork + shiny --------------------------------------------
|
||||||
// Shiny Pokémon were introduced in Gen 2 — no toggle for Gen 1 games.
|
// Shiny Pokémon were introduced in Gen 2 — no toggle for Gen 1 games.
|
||||||
@ -161,28 +107,9 @@ export async function PokemonDetail(nationalId) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
: null;
|
: 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() {
|
function paintArt() {
|
||||||
artHolder.replaceChildren(
|
artHolder.replaceChildren(
|
||||||
Sprite(activeForm ? activeForm.id : nationalId, {
|
Sprite(nationalId, {
|
||||||
style: artStyle,
|
style: artStyle,
|
||||||
shiny,
|
shiny,
|
||||||
versionGroup: st.versionGroup,
|
versionGroup: st.versionGroup,
|
||||||
@ -220,24 +147,6 @@ export async function PokemonDetail(nationalId) {
|
|||||||
b.append(label);
|
b.append(label);
|
||||||
return b;
|
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() {
|
function syncTrack() {
|
||||||
for (const [field, b] of [
|
for (const [field, b] of [
|
||||||
['favorite', favBtn],
|
['favorite', favBtn],
|
||||||
@ -251,132 +160,69 @@ export async function PokemonDetail(nationalId) {
|
|||||||
}
|
}
|
||||||
syncTrack();
|
syncTrack();
|
||||||
|
|
||||||
const allGames = st.versionGroup === 'all';
|
// ---- About panel -----------------------------------------------
|
||||||
let statBars = [];
|
const abilities = pokemon.abilities
|
||||||
|
.filter((a) => !a.is_hidden || vgGen >= 5)
|
||||||
|
.map((a) => prettify(a.ability.name) + (a.is_hidden ? ' (hidden)' : ''));
|
||||||
|
|
||||||
// ---- 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(
|
const aboutPanel = el(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'ppanel' },
|
{ class: 'ppanel' },
|
||||||
FlavorText(species, vg),
|
FlavorText(species, vg),
|
||||||
factsHolder,
|
|
||||||
el('h3', { class: 'ppanel__sub' }, 'Breeding'),
|
|
||||||
el(
|
el(
|
||||||
'dl',
|
'dl',
|
||||||
{ class: 'pfacts' },
|
{ class: 'pfacts' },
|
||||||
el('div', { class: 'fact' }, el('dt', {}, 'Gender'), genderCell(species.gender_rate)),
|
fact('Height', `${(pokemon.height / 10).toFixed(1)} m`),
|
||||||
fact(
|
fact('Weight', `${(pokemon.weight / 10).toFixed(1)} kg`),
|
||||||
'Egg groups',
|
abilities.length && vgGen >= 3 ? fact('Abilities', abilities.join(', ')) : null,
|
||||||
eggGroups.includes('Undiscovered') ? "Undiscovered (can't breed)" : eggGroups.join(', ') || '—',
|
fact('Introduced', prettify(species.generation.name)),
|
||||||
),
|
species.genera?.length
|
||||||
fact(
|
? fact(
|
||||||
'Egg cycles',
|
'Category',
|
||||||
hatch != null ? `${hatch} (~${((hatch + 1) * 255).toLocaleString()} steps)` : '—',
|
(species.genera.find((g) => g.language.name === 'en') || {}).genus || '—',
|
||||||
),
|
)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const statsPanel = el('div', { class: 'ppanel' }, ...statsContent(pk));
|
// ---- 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),
|
||||||
|
);
|
||||||
|
|
||||||
// ---- Evolution panel --------------------------------------
|
// ---- Evolution panel --------------------------------------
|
||||||
const evoPanel = el('div', { class: 'ppanel' }, el('p', { class: 'detail__muted' }, 'Loading…'));
|
const evoPanel = el('div', { class: 'ppanel' }, el('p', { class: 'detail__muted' }, 'Loading…'));
|
||||||
loadEvolution(species, st, snap, vgGen).then((node) => evoPanel.replaceChildren(node));
|
loadEvolution(species, st, snap, vgGen).then((node) => evoPanel.replaceChildren(node));
|
||||||
|
|
||||||
|
const allGames = st.versionGroup === 'all';
|
||||||
|
|
||||||
// ---- Moves panel ----------------------------------------
|
// ---- Moves panel ----------------------------------------
|
||||||
const movesPanel = el('div', { class: 'ppanel' }, movesContent(pk));
|
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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// ---- Locations panel ----------------------------------
|
// ---- Locations panel ----------------------------------
|
||||||
const locPanel = el('div', { class: 'ppanel' }, el('p', { class: 'detail__muted' }, 'Loading…'));
|
const locPanel = el('div', { class: 'ppanel' }, el('p', { class: 'detail__muted' }, 'Loading…'));
|
||||||
@ -449,55 +295,6 @@ export async function PokemonDetail(nationalId) {
|
|||||||
? `#${String(regionalNumber).padStart(3, '0')} · ${prettify(dex.name || dex.key)}`
|
? `#${String(regionalNumber).padStart(3, '0')} · ${prettify(dex.name || dex.key)}`
|
||||||
: `#${String(nationalId).padStart(4, '0')} · National`;
|
: `#${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(
|
clear(view).append(
|
||||||
el(
|
el(
|
||||||
'div',
|
'div',
|
||||||
@ -510,15 +307,14 @@ export async function PokemonDetail(nationalId) {
|
|||||||
{ class: 'phero__top' },
|
{ class: 'phero__top' },
|
||||||
el('a', { class: 'phero__back', href: '#/' }, '‹ Dex'),
|
el('a', { class: 'phero__back', href: '#/' }, '‹ Dex'),
|
||||||
el('span', { class: 'phero__num' }, numLabel),
|
el('span', { class: 'phero__num' }, numLabel),
|
||||||
el('div', { class: 'phero__actions' }, cryBtn, shinyBtn, favBtn),
|
el('div', { class: 'phero__actions' }, shinyBtn, favBtn),
|
||||||
),
|
),
|
||||||
el(
|
el(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'phero__head' },
|
{ class: 'phero__head' },
|
||||||
heroName,
|
el('h1', { class: 'phero__name' }, species.name.replace(/-/g, ' ')),
|
||||||
heroTypes,
|
el('div', { class: 'phero__types' }, ...types.map(TypeChip)),
|
||||||
),
|
),
|
||||||
formBar,
|
|
||||||
el(
|
el(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'phero__stage' },
|
{ class: 'phero__stage' },
|
||||||
@ -534,7 +330,7 @@ export async function PokemonDetail(nationalId) {
|
|||||||
el(
|
el(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'psheet' },
|
{ class: 'psheet' },
|
||||||
el('div', { class: 'ptrack' }, seenBtn, caughtBtn, teamBtn),
|
el('div', { class: 'ptrack' }, seenBtn, caughtBtn),
|
||||||
el('div', { class: 'psheet__tabs', role: 'tablist' }, ...tabButtons),
|
el('div', { class: 'psheet__tabs', role: 'tablist' }, ...tabButtons),
|
||||||
body,
|
body,
|
||||||
),
|
),
|
||||||
@ -549,10 +345,8 @@ export async function PokemonDetail(nationalId) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const off = settings.subscribe(() => syncTrack());
|
const off = settings.subscribe(() => syncTrack());
|
||||||
const offTeam = team.subscribe(syncTeamBtn);
|
|
||||||
onTeardown(view, () => {
|
onTeardown(view, () => {
|
||||||
off();
|
off();
|
||||||
offTeam();
|
|
||||||
offSwipe();
|
offSwipe();
|
||||||
});
|
});
|
||||||
return view;
|
return view;
|
||||||
@ -578,36 +372,3 @@ async function loadEvolution(species, st, snap, vgGen) {
|
|||||||
function fact(label, value) {
|
function fact(label, value) {
|
||||||
return el('div', { class: 'fact' }, el('dt', {}, label), el('dd', {}, 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,32 +4,31 @@ import { settings } from '../store/settings.js';
|
|||||||
import { ui } from '../store/ui.js';
|
import { ui } from '../store/ui.js';
|
||||||
import { entry } from '../store/selection.js';
|
import { entry } from '../store/selection.js';
|
||||||
import { prettify } from '../data/pokedex-resolver.js';
|
import { prettify } from '../data/pokedex-resolver.js';
|
||||||
import { getMachine, mapLimit } from '../data/api.js';
|
import { getMoveIndex, getItemIndex, getMove, mapLimit } from '../data/api.js';
|
||||||
import { Sprite } from '../components/Sprite.js';
|
import { Sprite } from '../components/Sprite.js';
|
||||||
import { TypeChip } from '../components/TypeChip.js';
|
import { TypeChip } from '../components/TypeChip.js';
|
||||||
|
|
||||||
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
|
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 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) =>
|
const itemSprite = (name) =>
|
||||||
`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/${name}.png`;
|
`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 = [
|
const TABS = [
|
||||||
{ id: 'pokemon', label: 'Pokémon' },
|
{ id: 'pokemon', label: 'Pokémon' },
|
||||||
{ id: 'moves', label: 'Moves' },
|
{ id: 'moves', label: 'Moves' },
|
||||||
{ id: 'items', label: 'Items' },
|
{ id: 'items', label: 'Items' },
|
||||||
{ id: 'abilities', label: 'Abilities' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const tmLabel = (raw) => {
|
/**
|
||||||
const m = raw.match(/^([a-z]+)0*(\d+)$/i);
|
* Unified lookup for Pokémon, moves and items. Pokémon come from the bundled
|
||||||
return m ? `${m[1].toUpperCase()}${String(Number(m[2])).padStart(2, '0')}` : raw.toUpperCase();
|
* 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.
|
||||||
|
*/
|
||||||
export async function SearchView() {
|
export async function SearchView() {
|
||||||
const snap = await loadSnapshot();
|
const snap = await loadSnapshot();
|
||||||
const view = el('section', { class: 'view search' });
|
const view = el('section', { class: 'view search' });
|
||||||
@ -63,7 +62,7 @@ export async function SearchView() {
|
|||||||
tab = t.id;
|
tab = t.id;
|
||||||
ui.set({ searchTab: t.id });
|
ui.set({ searchTab: t.id });
|
||||||
[...seg.children].forEach((b, i) => b.classList.toggle('is-active', TABS[i].id === tab));
|
[...seg.children].forEach((b, i) => b.classList.toggle('is-active', TABS[i].id === tab));
|
||||||
syncUI();
|
syncPlaceholder();
|
||||||
run();
|
run();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -72,112 +71,32 @@ export async function SearchView() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const filters = el('div', { class: 'lookup__filters' });
|
|
||||||
const note = el('p', { class: 'search__note' });
|
const note = el('p', { class: 'search__note' });
|
||||||
const results = el('div', { class: 'search__results' });
|
const results = el('div', { class: 'search__results' });
|
||||||
|
|
||||||
// ---- filter controls (rebuilt per tab) ---------------------------
|
function syncPlaceholder() {
|
||||||
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 =
|
input.placeholder =
|
||||||
|
tab === 'pokemon' ? 'Name, number or type…' : tab === 'moves' ? 'Move name…' : 'Item name…';
|
||||||
|
const st = settings.get();
|
||||||
|
note.textContent =
|
||||||
tab === 'pokemon'
|
tab === 'pokemon'
|
||||||
? 'Name, number or type…'
|
? `Every Pokémon, any game. Opening one shows it for ${
|
||||||
|
st.versionGroup === 'all' ? 'all games' : prettify(st.versionGroup)
|
||||||
|
}.`
|
||||||
: tab === 'moves'
|
: tab === 'moves'
|
||||||
? 'Move name…'
|
? 'Every move in the series.'
|
||||||
: tab === 'items'
|
: 'Every item in the series.';
|
||||||
? 'Item name…'
|
|
||||||
: 'Ability name or effect…';
|
|
||||||
buildFilters();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
view.append(
|
view.append(
|
||||||
el('header', { class: 'view__header' }, el('h1', {}, 'Search'), note),
|
el('header', { class: 'view__header' }, el('h1', {}, 'Search'), note),
|
||||||
seg,
|
seg,
|
||||||
filters,
|
|
||||||
input,
|
input,
|
||||||
results,
|
results,
|
||||||
);
|
);
|
||||||
syncUI();
|
syncPlaceholder();
|
||||||
|
|
||||||
// ---- Pokémon -------------------------------------------------
|
// ---- renderers -------------------------------------------------
|
||||||
function renderPokemon(q) {
|
function renderPokemon(q) {
|
||||||
const style = settings.get().spriteStyle;
|
const style = settings.get().spriteStyle;
|
||||||
const matches = snap.species
|
const matches = snap.species
|
||||||
@ -204,127 +123,85 @@ export async function SearchView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Moves (from snapshot, offline) -----------------------------
|
async function renderMoves(q, token) {
|
||||||
function renderMoves(q, token) {
|
if (!moveIndex) {
|
||||||
const st = settings.get();
|
results.append(el('p', { class: 'search__empty' }, 'Loading moves…'));
|
||||||
const vg = st.versionGroup;
|
try {
|
||||||
const type = ui.get().mvType;
|
moveIndex = (await getMoveIndex()).results;
|
||||||
const cls = ui.get().mvClass;
|
} catch {
|
||||||
const sort = ui.get().mvSort;
|
if (token === runToken) results.textContent = 'Move list unavailable offline.';
|
||||||
const tmOnly = !!ui.get().mvTm;
|
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.'));
|
||||||
|
|
||||||
let list = snap.moves.filter((m) => {
|
const metas = new Map();
|
||||||
if (q && !loose(m.name).includes(q)) return false;
|
for (const m of matches) {
|
||||||
if (type && m.type !== type) return false;
|
const meta = el('span', { class: 'search__types' });
|
||||||
if (cls && m.damageClass !== cls) return false;
|
metas.set(m.name, meta);
|
||||||
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(
|
results.append(
|
||||||
el(
|
el(
|
||||||
'a',
|
'a',
|
||||||
{ class: 'search__row search__row--slim', href: `#/move/${m.id}` },
|
{ class: 'search__row search__row--slim', href: `#/move/${idFromUrl(m.url)}` },
|
||||||
tmCell,
|
|
||||||
el('span', { class: 'search__name' }, m.name.replace(/-/g, ' ')),
|
el('span', { class: 'search__name' }, m.name.replace(/-/g, ' ')),
|
||||||
el(
|
meta,
|
||||||
'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` : ''),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (list.length > shown.length) {
|
// Enrich a short result set with type / class / power after a pause.
|
||||||
results.append(el('p', { class: 'search__empty' }, `Showing ${shown.length} of ${list.length} — refine to see more.`));
|
clearTimeout(enrichTimer);
|
||||||
}
|
if (matches.length <= 24) {
|
||||||
|
|
||||||
if (tmOnly && machineCells.size) {
|
|
||||||
clearTimeout(enrichTimer);
|
|
||||||
enrichTimer = setTimeout(() => {
|
enrichTimer = setTimeout(() => {
|
||||||
mapLimit([...machineCells.values()], 6, async ({ cell, m }) => {
|
mapLimit(matches, 6, async (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 {
|
try {
|
||||||
const mc = await getMachine(hit.id);
|
const d = await getMove(idFromUrl(m.url));
|
||||||
cell.textContent = tmLabel(mc.item.name);
|
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` : ''),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
/* leave blank */
|
/* leave bare */
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, 200);
|
}, 220);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Items (from snapshot, offline) ---------------------------
|
async function renderItems(q, token) {
|
||||||
function renderItems(q) {
|
if (!itemIndex) {
|
||||||
const cat = ui.get().itCat;
|
results.append(el('p', { class: 'search__empty' }, 'Loading items…'));
|
||||||
const sort = ui.get().itSort;
|
try {
|
||||||
let list = snap.items.filter((it) => {
|
itemIndex = (await getItemIndex()).results;
|
||||||
if (q && !loose(it.name).includes(q)) return false;
|
} catch {
|
||||||
if (cat && it.category !== cat) return false;
|
if (token === runToken) results.textContent = 'Item list unavailable offline.';
|
||||||
return true;
|
return;
|
||||||
});
|
}
|
||||||
list.sort((a, b) => (sort === 'id' ? a.id - b.id : a.name.localeCompare(b.name)));
|
if (token !== runToken) return;
|
||||||
|
clear(results);
|
||||||
note.textContent = `${list.length} item${list.length === 1 ? '' : 's'}`;
|
}
|
||||||
const shown = list.slice(0, 400);
|
const matches = itemIndex.filter((it) => loose(it.name).includes(q)).slice(0, 80);
|
||||||
for (const it of shown) {
|
if (!matches.length) return void results.append(el('p', { class: 'search__empty' }, 'No matches.'));
|
||||||
const icon = el('img', { class: 'search__item-icon', loading: 'lazy', alt: '', src: itemSprite(it.name) });
|
for (const it of matches) {
|
||||||
|
const icon = el('img', {
|
||||||
|
class: 'search__item-icon',
|
||||||
|
loading: 'lazy',
|
||||||
|
alt: '',
|
||||||
|
src: itemSprite(it.name),
|
||||||
|
});
|
||||||
icon.addEventListener('error', () => icon.remove(), { once: true });
|
icon.addEventListener('error', () => icon.remove(), { once: true });
|
||||||
results.append(
|
results.append(
|
||||||
el(
|
el(
|
||||||
'a',
|
'a',
|
||||||
{ class: 'search__row', href: `#/item/${it.id}` },
|
{ class: 'search__row', href: `#/item/${idFromUrl(it.url)}` },
|
||||||
icon,
|
icon,
|
||||||
el('span', { class: 'search__name' }, it.name.replace(/-/g, ' ')),
|
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 || '—'),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -334,18 +211,12 @@ export async function SearchView() {
|
|||||||
const token = ++runToken;
|
const token = ++runToken;
|
||||||
const q = loose(query);
|
const q = loose(query);
|
||||||
clear(results);
|
clear(results);
|
||||||
note.textContent = '';
|
|
||||||
if (tab === 'pokemon') {
|
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);
|
if (q) renderPokemon(q);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (tab === 'moves') renderMoves(q, token);
|
if (!q) return;
|
||||||
else if (tab === 'items') renderItems(q);
|
(tab === 'moves' ? renderMoves : renderItems)(q, token);
|
||||||
else renderAbilities(q);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
run();
|
run();
|
||||||
|
|||||||
@ -63,15 +63,6 @@ export async function SettingsView() {
|
|||||||
'Default detail view to shiny sprites',
|
'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…');
|
const storageNote = el('p', { class: 'settings__note' }, 'Calculating storage…');
|
||||||
if (navigator.storage?.estimate) {
|
if (navigator.storage?.estimate) {
|
||||||
navigator.storage.estimate().then(({ usage = 0, quota = 0 }) => {
|
navigator.storage.estimate().then(({ usage = 0, quota = 0 }) => {
|
||||||
@ -102,7 +93,7 @@ export async function SettingsView() {
|
|||||||
|
|
||||||
view.append(
|
view.append(
|
||||||
el('header', { class: 'view__header' }, el('h1', {}, 'Settings')),
|
el('header', { class: 'view__header' }, el('h1', {}, 'Settings')),
|
||||||
el('div', { class: 'settings__group' }, themeField, accentField, spriteField, shinyField, teamNavField),
|
el('div', { class: 'settings__group' }, themeField, accentField, spriteField, shinyField),
|
||||||
|
|
||||||
el('h2', {}, 'Your data'),
|
el('h2', {}, 'Your data'),
|
||||||
storageNote,
|
storageNote,
|
||||||
|
|||||||
@ -1,283 +0,0 @@
|
|||||||
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