Compare commits

..

7 Commits

Author SHA1 Message Date
f9f2b8f1e8 Forms: keep formes that differ only by ability (Basculin, Toxtricity)
The cosmetic-form filter now also requires identical abilities before
dropping a variety, so gameplay-relevant look-alike formes (Basculin
Blue/White-Striped, Toxtricity Low Key) are no longer discarded.
274 forms across the snapshot (+10 'other').

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 11:14:35 -04:00
40667cbdb6 Forms & variants
- Snapshot: each species gains a forms[] list (slug, form id, label,
  category, types, stats, BST, height, weight) built from its non-default
  varieties — 264 forms across 203 species (Megas, G-max, regional,
  alt formes; cosmetic-only forms filtered out). Snapshot ~914 KB.
- Detail page: a form switcher (Base + each form). Selecting a form
  re-fetches its /pokemon and rebuilds the type-dependent parts —
  hero name/types/artwork, --type theming, stat bars + total, defensive
  matchups, STAB coverage, abilities, EV yield, held items, and learnset.
  Species-level data (flavour, breeding, evolution) stays put.
- Grid cards show a '+N forms' badge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 11:11:07 -04:00
415cf3edec Abilities lookup + detail; Natures reference
- Snapshot bakes an abilities index (373: name, generation, effect, and the
  species that have it). Snapshot ~872 KB / 133 KB gzip.
- Search gains an 'Abilities' tab — browse all 373 offline, filter/search by
  name or effect, sort A–Z / most-Pokemon / newest
- #/ability/:id detail: effect + a grid of every Pokemon with the ability
  (all from the snapshot, no fetch)
- Pokemon detail: the Abilities line is now tappable, linking to each ability
- #/natures: the full 25-nature +10%/-10% table; linked from the Team view

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 11:00:14 -04:00
79a5efaf11 Browsable moves & items lists (offline)
- Snapshot now bakes a moves index (937: type, damage class, power,
  accuracy, pp, priority, generation, machine ids per version group) and an
  items index (2223: id, name, category — resolved via the 54 item
  categories). ~806 KB / 117 KB gzip.
- Search Moves/Items tabs show the FULL list when the query is empty:
  - Moves: filter by type / damage class / 'TMs only' (for the selected
    game), sort by A–Z / power / accuracy / newest; TM numbers resolve
    lazily via /machine. Rich rows (type + class + power) with no fetch.
  - Items: filter by category, sort A–Z / dex order; sprite + category tag.
  - Result count shown; capped at 400 with a 'refine to see more' note.
- Both tabs are now fully offline (dropped the online name-index calls).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 10:42:03 -04:00
ddc84c2a48 Team Builder + Compare views
- New #/team view: a persisted lineup of up to 6 Pokémon (pdx.team store)
  with a search-picker overlay, plus a Coverage/Compare toggle
  - Coverage: 18x6 weakness table (colour-coded multipliers + per-type weak
    count), offensive STAB gaps, team at-a-glance (avg BST, fastest, bulkiest)
  - Compare: per-member column table of types + 6 stats + BST with mini bars
    and row-max highlighting; scrolls horizontally past 4 members
- '+ Team' / '✓ In team' toggle on the Pokémon detail page (disabled when full)
- Nav gains a Team item; a Settings toggle ('Show Team in the navigation
  bar', on by default) hides it from the bar without removing the route
- type-chart.js: defenseVector() helper

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 10:33:52 -04:00
5717b0a38a Compact feed header on phones so more Pokémon are visible
- Filter chips + sort + type/gen/random collapse behind a 'Filters & sort'
  toggle on < 900px (always shown on desktop); toggle carries a badge with
  the active-filter count; open state persists
- Smaller title / progress ring / search on < 560px
- Shorter dex cards on phones (tighter padding, 94px sprite): ~209px vs
  ~250px, so ~4-5 Pokémon fit above the fold instead of ~2-3

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 10:15:42 -04:00
2e4572428a Dense-data pass: training/breeding facts, STAB coverage, filters, cries
- Detail About tab: EV yield, base EXP, catch rate + ~%, base friendship,
  growth rate + total EXP, held items; Breeding block with a gender-ratio
  bar, egg groups, egg cycles/steps
- Stats tab: 'STAB coverage (attacking)' — types the Pokemon's STAB hits
  hard / is walled by (data/type-chart.js offensiveSummary)
- Feed: Type and Generation filter dropdowns + a Random button; cards now
  resolve typings for the selected game's generation via snapshot pastTypes
- Recently-viewed strip on the feed (ui.recent, capped at 12)
- Play-cry button on the detail hero; service worker caches PokeAPI cries

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 10:06:36 -04:00
22 changed files with 2056 additions and 187 deletions

View File

@ -71,6 +71,10 @@ 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
@ -80,9 +84,13 @@ 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 (offline, from the snapshot), Moves - **Search** — tabbed lookup: Pokémon, Moves and Items, all browsable
and Items (lazy name index + on-demand detail, SW-cached), each with its offline from the snapshot. Moves filter by type / damage class / "TMs in
own detail page. Query, scroll and tab persist. this game" and sort by power / accuracy / recency; Items filter by
category. Each has its own detail page. Query, scroll, tab and filters
persist.
- **Team** — a lineup of up to 6 with a Coverage table (weaknesses, STAB
gaps) and a Compare table; toggleable in the nav from Settings.
- **Settings** — theme (System / Light / Dark / Black / Sepia) + accent - **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,

View File

@ -30,6 +30,34 @@ 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);
@ -152,14 +180,9 @@ async function main() {
.map((r) => idFromUrl(r.url)) .map((r) => idFromUrl(r.url))
.sort((a, b) => a - b); .sort((a, b) => a - b);
console.log(`Fetching ${ids.length} species (stats, typings, flags) …`); const statMap = (pk) => {
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]));
const stats = { return {
hp: s.hp ?? 0, hp: s.hp ?? 0,
atk: s.attack ?? 0, atk: s.attack ?? 0,
def: s.defense ?? 0, def: s.defense ?? 0,
@ -167,7 +190,61 @@ 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),
@ -196,6 +273,66 @@ async function main() {
}; };
}); });
// ---- Moves index -------------------------------------------
const moveIndex = await api('move?limit=100000');
console.log(`Fetching ${moveIndex.results.length} moves …`);
const moves = await mapLimit(moveIndex.results, CONCURRENCY, async (r) => {
const m = await api(r.url);
return {
id: m.id,
name: m.name,
type: m.type?.name || 'normal',
damageClass: m.damage_class?.name || null,
power: m.power,
accuracy: m.accuracy,
pp: m.pp,
priority: m.priority,
generation: idFromUrl(m.generation.url),
// version-group -> machine id (resolve to TM/HM number on demand)
machines: (m.machines || []).map((x) => ({
vg: x.version_group.name,
id: idFromUrl(x.machine.url),
})),
};
});
moves.sort((a, b) => a.id - b.id);
// ---- Abilities index ------------------------------------------
const abilityIndex = await api('ability?limit=100000');
console.log(`Fetching ${abilityIndex.results.length} abilities …`);
const abilities = await mapLimit(abilityIndex.results, CONCURRENCY, async (r) => {
const a = await api(r.url);
const en = (a.effect_entries || []).find((e) => e.language.name === 'en');
const flavour = [...(a.flavor_text_entries || [])]
.reverse()
.find((e) => e.language.name === 'en');
return {
id: a.id,
name: a.name,
generation: idFromUrl(a.generation.url),
isMainSeries: a.is_main_series,
effect:
(en && (en.short_effect || en.effect)) ||
(flavour && flavour.flavor_text) ||
'',
pokemon: [
...new Set(a.pokemon.map((x) => idFromUrl(x.pokemon.url)).filter((n) => n <= 100000)),
],
};
});
abilities.sort((a, b) => a.id - b.id);
// ---- Item categories (gives every item a category cheaply) ----
const catIndex = await api('item-category?limit=100');
const items = [];
await mapLimit(catIndex.results, CONCURRENCY, async (c) => {
const cat = await api(c.name.startsWith('http') ? c.name : `item-category/${c.name}`);
for (const it of cat.items) {
items.push({ id: idFromUrl(it.url), name: it.name, category: cat.name });
}
});
items.sort((a, b) => a.id - b.id);
// ---- Write ---------------------------------------------------- // ---- Write ----------------------------------------------------
const snapshot = { const snapshot = {
meta: { meta: {
@ -203,6 +340,9 @@ 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,
}, },
@ -210,6 +350,9 @@ async function main() {
types, types,
generations, generations,
species, species,
moves,
items,
abilities,
pokedexes, pokedexes,
versionGroups, versionGroups,
}; };

View File

@ -3,15 +3,17 @@ 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, boxed = false, metric = null } = {}) { export function Card(species, number, { spriteStyle = 'official', versionGroup, gen = 9, boxed = false, metric = null } = {}) {
const state = entry(species.id); const state = entry(species.id);
const mainType = (species.types || [])[0] || 'normal'; const types = typesForGen(species, gen);
const mainType = types[0] || 'normal';
const num = String(number ?? species.id).padStart(3, '0'); const 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';
@ -58,7 +60,10 @@ 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' }, ...(species.types || []).map(TypeChip)), el('span', { class: 'card__types' }, ...types.map(TypeChip)),
species.forms?.length
? el('span', { class: 'card__forms' }, `+${species.forms.length} form${species.forms.length > 1 ? 's' : ''}`)
: null,
metric ? el('span', { class: 'card__metric' }, metric) : null, metric ? el('span', { class: 'card__metric' }, metric) : null,
), ),
); );

View File

@ -1,8 +1,10 @@
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') },
@ -10,41 +12,47 @@ 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. Links derive their active state from the hash; the Games * on wide screens. The Games item summons an overlay; optional items (Team)
* item summons an overlay instead of navigating. * can be hidden from Settings.
*/ */
export function Nav() { export function Nav() {
const links = ITEMS.map((item) => { const nav = el('nav', { class: 'nav', 'aria-label': 'Primary' });
const inner = [ let 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);
});
const nav = el( function build() {
'nav', const st = settings.get();
{ class: 'nav', 'aria-label': 'Primary' }, const items = ITEMS.filter((it) => !it.optional || st[it.optional]);
el('span', { class: 'nav__brand' }, 'Pokédex'), links = items.map((item) => {
...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);
});
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 || '#/';
ITEMS.forEach((item, i) => { (nav._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;
} }

View File

@ -0,0 +1,98 @@
import { el, clear } from '../lib/dom.js';
import { loadSnapshot } from '../data/snapshot.js';
import { settings } from '../store/settings.js';
import { Sprite } from './Sprite.js';
import { TypeChip } from './TypeChip.js';
let openInstance = null;
/**
* A summonable overlay to pick a Pokémon from the full snapshot list.
* `onPick(id)` fires per selection; the sheet stays open so you can add
* several. Dismiss with the backdrop, , or Escape.
*/
export async function openPokemonPicker(onPick) {
if (openInstance) return;
const snap = await loadSnapshot();
const backdrop = el('div', {
class: 'sheet-backdrop',
onclick: (e) => {
if (e.target === backdrop) close();
},
});
const panel = el('div', {
class: 'gsheet',
role: 'dialog',
'aria-modal': 'true',
'aria-label': 'Add a Pokémon',
});
const onKey = (e) => {
if (e.key === 'Escape') close();
};
function close() {
document.removeEventListener('keydown', onKey);
backdrop.classList.remove('is-open');
setTimeout(() => {
backdrop.remove();
openInstance = null;
}, 220);
}
const results = el('div', { class: 'picker__list' });
const input = el('input', {
class: 'search__input',
type: 'search',
placeholder: 'Name, number or type…',
autofocus: true,
oninput: (e) => run(e.target.value),
});
function run(raw) {
const q = raw.trim().toLowerCase();
clear(results);
if (!q) return;
const style = settings.get().spriteStyle;
const matches = snap.species
.filter(
(s) =>
s.name.replace(/-/g, ' ').includes(q) ||
String(s.id) === q ||
(s.types || []).some((t) => t === q),
)
.slice(0, 50);
for (const s of matches) {
results.append(
el(
'button',
{
class: 'picker__row',
type: 'button',
onclick: () => onPick(s.id),
},
Sprite(s.id, { style, alt: s.name, size: 44 }),
el('span', { class: 'search__num' }, `#${String(s.id).padStart(4, '0')}`),
el('span', { class: 'search__name' }, s.name.replace(/-/g, ' ')),
el('span', { class: 'search__types' }, ...(s.types || []).map(TypeChip)),
),
);
}
}
panel.append(
el(
'div',
{ class: 'gsheet__head' },
el('h2', {}, 'Add a Pokémon'),
el('button', { class: 'gsheet__close', type: 'button', 'aria-label': 'Close', onclick: close }, '✕'),
),
input,
results,
);
backdrop.append(panel);
document.body.append(backdrop);
document.addEventListener('keydown', onKey);
openInstance = backdrop;
requestAnimationFrame(() => backdrop.classList.add('is-open'));
}

39
src/data/natures.js Normal file
View File

@ -0,0 +1,39 @@
/**
* The 25 natures. Each raises one stat by 10% and lowers another by 10%;
* the five where up === down are neutral. HP is never affected.
*/
export const STAT_LABEL = {
atk: 'Attack',
def: 'Defense',
spa: 'Sp. Atk',
spd: 'Sp. Def',
spe: 'Speed',
};
export const NATURES = [
{ name: 'Hardy', up: 'atk', down: 'atk' },
{ name: 'Lonely', up: 'atk', down: 'def' },
{ name: 'Brave', up: 'atk', down: 'spe' },
{ name: 'Adamant', up: 'atk', down: 'spa' },
{ name: 'Naughty', up: 'atk', down: 'spd' },
{ name: 'Bold', up: 'def', down: 'atk' },
{ name: 'Docile', up: 'def', down: 'def' },
{ name: 'Relaxed', up: 'def', down: 'spe' },
{ name: 'Impish', up: 'def', down: 'spa' },
{ name: 'Lax', up: 'def', down: 'spd' },
{ name: 'Timid', up: 'spe', down: 'atk' },
{ name: 'Hasty', up: 'spe', down: 'def' },
{ name: 'Serious', up: 'spe', down: 'spe' },
{ name: 'Jolly', up: 'spe', down: 'spa' },
{ name: 'Naive', up: 'spe', down: 'spd' },
{ name: 'Modest', up: 'spa', down: 'atk' },
{ name: 'Mild', up: 'spa', down: 'def' },
{ name: 'Quiet', up: 'spa', down: 'spe' },
{ name: 'Bashful', up: 'spa', down: 'spa' },
{ name: 'Rash', up: 'spa', down: 'spd' },
{ name: 'Calm', up: 'spd', down: 'atk' },
{ name: 'Gentle', up: 'spd', down: 'def' },
{ name: 'Sassy', up: 'spd', down: 'spe' },
{ name: 'Careful', up: 'spd', down: 'spa' },
{ name: 'Quirky', up: 'spd', down: 'spd' },
];

View File

@ -20,6 +20,13 @@ 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;

View File

@ -64,6 +64,31 @@ 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': [...] }

13
src/lib/type-resolve.js Normal file
View File

@ -0,0 +1,13 @@
/**
* A snapshot species' typing as it was in a given generation, using the
* `pastTypes` history baked into the snapshot (Gen 1 Magnemite is pure
* Electric, pre-Gen 6 Clefairy is Normal, ). Falls back to current types.
*/
export function typesForGen(species, gen = 9) {
const past = species && species.pastTypes;
if (past && past.length) {
const era = [...past].sort((a, b) => a.gen - b.gen).find((p) => p.gen >= gen);
if (era) return era.types;
}
return (species && species.types) || [];
}

View File

@ -5,6 +5,9 @@ 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 = [
@ -12,6 +15,9 @@ 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() },
]; ];

View File

@ -17,6 +17,7 @@ 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',
}); });

28
src/store/team.js Normal file
View File

@ -0,0 +1,28 @@
import { createStore } from './createStore.js';
/**
* The working "lineup" up to 6 Pokémon (national dex ids) analysed by the
* Team view (coverage) and Compare view. Persisted.
*/
export const team = createStore('pdx.team', { members: [] });
export const MAX_TEAM = 6;
export function addToTeam(id) {
team.set((s) => {
if (s.members.includes(id) || s.members.length >= MAX_TEAM) return s;
return { ...s, members: [...s.members, id] };
});
}
export function removeFromTeam(id) {
team.set((s) => ({ ...s, members: s.members.filter((x) => x !== id) }));
}
export function clearTeam() {
team.set({ members: [] });
}
export function inTeam(id) {
return team.get().members.includes(id);
}

View File

@ -13,4 +13,16 @@ 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',
}); });

View File

@ -1825,3 +1825,608 @@
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));
}

View File

@ -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/sprites/') || url.pathname.includes('/PokeAPI/cries/')),
new CacheFirst({ new CacheFirst({
cacheName: 'pokeapi-img', cacheName: 'pokeapi-img',
plugins: [ plugins: [

View File

@ -0,0 +1,66 @@
import { el, clear } from '../lib/dom.js';
import { loadSnapshot } from '../data/snapshot.js';
import { settings } from '../store/settings.js';
import { Sprite } from '../components/Sprite.js';
export async function AbilityDetail(id) {
const view = el('section', { class: 'view lookup' });
const snap = await loadSnapshot();
const a = snap.abilityById.get(id);
if (!a) {
view.append(
el(
'div',
{ class: 'view--error' },
el('h1', {}, 'Unknown ability'),
el('a', { class: 'button', href: '#/search' }, 'Back to lookup'),
),
);
return view;
}
const style = settings.get().spriteStyle;
const mons = a.pokemon.map((pid) => snap.speciesById.get(pid)).filter(Boolean);
mons.sort((x, y) => x.id - y.id);
clear(view).append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/search' }, ' Lookup')),
el(
'header',
{ class: 'lookup__head lookup__head--item' },
el(
'div',
{},
el('h1', {}, a.name.replace(/-/g, ' ')),
el('span', { class: 'lookup__class' }, `Introduced Gen ${a.generation}`),
),
),
el(
'section',
{ class: 'detail__section' },
el('h2', {}, 'Effect'),
el('p', {}, a.effect || 'No description available.'),
),
el(
'section',
{ class: 'detail__section' },
el('h2', {}, `Pokémon with this ability (${mons.length})`),
mons.length
? el(
'div',
{ class: 'ab-mons' },
...mons.map((sp) =>
el(
'a',
{ class: 'ab-mon', href: `#/pokemon/${sp.id}` },
Sprite(sp.id, { style, alt: sp.name, size: 48 }),
el('span', {}, sp.name.replace(/-/g, ' ')),
),
),
)
: el('p', { class: 'detail__muted' }, 'None on record.'),
),
);
return view;
}

View File

@ -6,6 +6,13 @@ 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 },
@ -73,10 +80,13 @@ 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();
@ -109,6 +119,7 @@ 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),
@ -151,6 +162,54 @@ 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 });
@ -175,6 +234,58 @@ 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',
@ -191,15 +302,10 @@ 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,
), ),
filterBar, toolsToggle,
el( tools,
'div',
{ class: 'feed-sort' },
el('span', { class: 'feed-sort__label' }, 'Sort'),
sortSelect,
dirBtn,
),
), ),
recentStrip,
grid, grid,
); );
@ -228,9 +334,8 @@ 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 boxed = const gen = snap.versionGroupByKey.get(st.versionGroup)?.generation ?? 9;
st.spriteStyle === 'game' && const boxed = st.spriteStyle === 'game' && gen <= 2;
(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 }) => {
@ -242,6 +347,8 @@ 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);
}); });
@ -255,12 +362,14 @@ 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),
}); });

36
src/views/NaturesView.js Normal file
View File

@ -0,0 +1,36 @@
import { el } from '../lib/dom.js';
import { NATURES, STAT_LABEL } from '../data/natures.js';
export function NaturesView() {
const view = el('section', { class: 'view lookup' });
view.append(
el(
'header',
{ class: 'view__header' },
el('h1', {}, 'Natures'),
el('p', {}, 'Each nature raises one stat by 10% and lowers another by 10%. HP is never affected; five natures are neutral.'),
),
el(
'div',
{ class: 'natures' },
el(
'div',
{ class: 'natures__row natures__row--head' },
el('span', {}, 'Nature'),
el('span', { class: 'natures__up' }, '▲ raises'),
el('span', { class: 'natures__down' }, '▼ lowers'),
),
...NATURES.map((n) => {
const neutral = n.up === n.down;
return el(
'div',
{ class: `natures__row${neutral ? ' is-neutral' : ''}` },
el('span', { class: 'natures__name' }, n.name),
el('span', { class: 'natures__up' }, neutral ? '—' : STAT_LABEL[n.up]),
el('span', { class: 'natures__down' }, neutral ? '—' : STAT_LABEL[n.down]),
);
}),
),
);
return view;
}

View File

@ -6,6 +6,7 @@ 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';
@ -15,9 +16,47 @@ 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 || [])
@ -81,11 +120,26 @@ export async function PokemonDetail(nationalId) {
return view; return view;
} }
const types = typesForGeneration(pokemon, vgGen); // Recently-viewed history for the feed strip (most recent first, capped).
const mainType = types[0]; ui.set((s) => ({
view.style.setProperty('--type-main', typeHex(mainType)); recent: [nationalId, ...(s.recent || []).filter((x) => x !== nationalId)].slice(0, 12),
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.
@ -107,9 +161,28 @@ 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(nationalId, { Sprite(activeForm ? activeForm.id : nationalId, {
style: artStyle, style: artStyle,
shiny, shiny,
versionGroup: st.versionGroup, versionGroup: st.versionGroup,
@ -147,6 +220,24 @@ 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],
@ -160,69 +251,132 @@ export async function PokemonDetail(nationalId) {
} }
syncTrack(); syncTrack();
// ---- About panel ----------------------------------------------- const allGames = st.versionGroup === 'all';
const abilities = pokemon.abilities let statBars = [];
.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' },
fact('Height', `${(pokemon.height / 10).toFixed(1)} m`), el('div', { class: 'fact' }, el('dt', {}, 'Gender'), genderCell(species.gender_rate)),
fact('Weight', `${(pokemon.weight / 10).toFixed(1)} kg`), fact(
abilities.length && vgGen >= 3 ? fact('Abilities', abilities.join(', ')) : null, 'Egg groups',
fact('Introduced', prettify(species.generation.name)), eggGroups.includes('Undiscovered') ? "Undiscovered (can't breed)" : eggGroups.join(', ') || '—',
species.genera?.length ),
? fact( fact(
'Category', 'Egg cycles',
(species.genera.find((g) => g.language.name === 'en') || {}).genus || '—', hatch != null ? `${hatch} (~${((hatch + 1) * 255).toLocaleString()} steps)` : '—',
) ),
: null,
), ),
); );
// ---- Stats panel --------------------------------------------- const statsPanel = el('div', { class: 'ppanel' }, ...statsContent(pk));
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( const movesPanel = el('div', { class: 'ppanel' }, movesContent(pk));
'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…'));
@ -295,6 +449,55 @@ 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',
@ -307,14 +510,15 @@ 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' }, shinyBtn, favBtn), el('div', { class: 'phero__actions' }, cryBtn, shinyBtn, favBtn),
), ),
el( el(
'div', 'div',
{ class: 'phero__head' }, { class: 'phero__head' },
el('h1', { class: 'phero__name' }, species.name.replace(/-/g, ' ')), heroName,
el('div', { class: 'phero__types' }, ...types.map(TypeChip)), heroTypes,
), ),
formBar,
el( el(
'div', 'div',
{ class: 'phero__stage' }, { class: 'phero__stage' },
@ -330,7 +534,7 @@ export async function PokemonDetail(nationalId) {
el( el(
'div', 'div',
{ class: 'psheet' }, { class: 'psheet' },
el('div', { class: 'ptrack' }, seenBtn, caughtBtn), el('div', { class: 'ptrack' }, seenBtn, caughtBtn, teamBtn),
el('div', { class: 'psheet__tabs', role: 'tablist' }, ...tabButtons), el('div', { class: 'psheet__tabs', role: 'tablist' }, ...tabButtons),
body, body,
), ),
@ -345,8 +549,10 @@ 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;
@ -372,3 +578,36 @@ 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,
),
);
}

View File

@ -4,31 +4,32 @@ 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 { getMoveIndex, getItemIndex, getMove, mapLimit } from '../data/api.js'; import { getMachine, 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) => {
* Unified lookup for Pokémon, moves and items. Pokémon come from the bundled const m = raw.match(/^([a-z]+)0*(\d+)$/i);
* snapshot (offline); moves and items lazy-load a name index from PokéAPI return m ? `${m[1].toUpperCase()}${String(Number(m[2])).padStart(2, '0')}` : raw.toUpperCase();
* (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' });
@ -62,7 +63,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));
syncPlaceholder(); syncUI();
run(); run();
}, },
}, },
@ -71,32 +72,112 @@ 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' });
function syncPlaceholder() { // ---- filter controls (rebuilt per tab) ---------------------------
function pill(label, options, value, onChange) {
return el(
'select',
{ class: 'feed-sort__select', onchange: (e) => onChange(e.target.value) },
...options.map(([v, t]) => el('option', { value: v, selected: v === value }, t)),
);
}
function buildFilters() {
clear(filters);
if (tab === 'moves') {
filters.append(
pill('mvType', [['', 'Any type'], ...TYPES.map((t) => [t, prettify(t)])], ui.get().mvType, (v) => {
ui.set({ mvType: v });
run();
}),
pill(
'mvClass',
[['', 'Any category'], ['physical', 'Physical'], ['special', 'Special'], ['status', 'Status']],
ui.get().mvClass,
(v) => {
ui.set({ mvClass: v });
run();
},
),
pill(
'mvSort',
[['name', 'AZ'], ['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', 'AZ'], ['id', 'Dex order']], ui.get().itSort, (v) => {
ui.set({ itSort: v });
run();
}),
);
} else if (tab === 'abilities') {
filters.append(
pill(
'abSort',
[['name', 'AZ'], ['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'
? `Every Pokémon, any game. Opening one shows it for ${ ? 'Name, number or type…'
st.versionGroup === 'all' ? 'all games' : prettify(st.versionGroup)
}.`
: tab === 'moves' : tab === 'moves'
? 'Every move in the series.' ? 'Move name…'
: 'Every item in the series.'; : tab === 'items'
? '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,
); );
syncPlaceholder(); syncUI();
// ---- renderers ------------------------------------------------- // ---- Pokémon -------------------------------------------------
function renderPokemon(q) { function renderPokemon(q) {
const style = settings.get().spriteStyle; const style = settings.get().spriteStyle;
const matches = snap.species const matches = snap.species
@ -123,85 +204,127 @@ export async function SearchView() {
} }
} }
async function renderMoves(q, token) { // ---- Moves (from snapshot, offline) -----------------------------
if (!moveIndex) { function renderMoves(q, token) {
results.append(el('p', { class: 'search__empty' }, 'Loading moves…')); const st = settings.get();
try { const vg = st.versionGroup;
moveIndex = (await getMoveIndex()).results; const type = ui.get().mvType;
} catch { const cls = ui.get().mvClass;
if (token === runToken) results.textContent = 'Move list unavailable offline.'; const sort = ui.get().mvSort;
return; const tmOnly = !!ui.get().mvTm;
}
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.'));
const metas = new Map(); let list = snap.moves.filter((m) => {
for (const m of matches) { if (q && !loose(m.name).includes(q)) return false;
const meta = el('span', { class: 'search__types' }); if (type && m.type !== type) return false;
metas.set(m.name, meta); if (cls && m.damageClass !== cls) return false;
if (tmOnly && !m.machines.some((x) => vg === 'all' || x.vg === vg)) return false;
return true;
});
list.sort((a, b) => {
if (sort === 'power') return (b.power ?? -1) - (a.power ?? -1) || a.name.localeCompare(b.name);
if (sort === 'accuracy') return (b.accuracy ?? -1) - (a.accuracy ?? -1) || a.name.localeCompare(b.name);
if (sort === 'gen') return b.generation - a.generation || a.name.localeCompare(b.name);
return a.name.localeCompare(b.name);
});
note.textContent = `${list.length} move${list.length === 1 ? '' : 's'}`;
const shown = list.slice(0, 400);
const machineCells = new Map();
for (const m of shown) {
const tmCell = tmOnly ? el('span', { class: 'moverow__tm' }) : null;
if (tmCell) machineCells.set(m.id, { cell: tmCell, m });
results.append( results.append(
el( el(
'a', 'a',
{ class: 'search__row search__row--slim', href: `#/move/${idFromUrl(m.url)}` }, { class: 'search__row search__row--slim', href: `#/move/${m.id}` },
tmCell,
el('span', { class: 'search__name' }, m.name.replace(/-/g, ' ')), el('span', { class: 'search__name' }, m.name.replace(/-/g, ' ')),
meta, el(
'span',
{ class: 'search__types' },
TypeChip(m.type),
el('span', { class: 'moverow__cat', dataset: { cat: m.damageClass || '' } }, DMG[m.damageClass] || '—'),
el('span', { class: 'search__num' }, m.power != null ? `${m.power} pw` : ''),
),
), ),
); );
} }
// Enrich a short result set with type / class / power after a pause. if (list.length > shown.length) {
clearTimeout(enrichTimer); results.append(el('p', { class: 'search__empty' }, `Showing ${shown.length} of ${list.length} — refine to see more.`));
if (matches.length <= 24) { }
if (tmOnly && machineCells.size) {
clearTimeout(enrichTimer);
enrichTimer = setTimeout(() => { enrichTimer = setTimeout(() => {
mapLimit(matches, 6, async (m) => { mapLimit([...machineCells.values()], 6, async ({ cell, m }) => {
if (token !== runToken || !cell.isConnected) return;
const hit = m.machines.find((x) => x.vg === vg) || m.machines[m.machines.length - 1];
if (!hit) return;
try { try {
const d = await getMove(idFromUrl(m.url)); const mc = await getMachine(hit.id);
const meta = metas.get(m.name); cell.textContent = tmLabel(mc.item.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 bare */ /* leave blank */
} }
}); });
}, 220); }, 200);
} }
} }
async function renderItems(q, token) { // ---- Items (from snapshot, offline) ---------------------------
if (!itemIndex) { function renderItems(q) {
results.append(el('p', { class: 'search__empty' }, 'Loading items…')); const cat = ui.get().itCat;
try { const sort = ui.get().itSort;
itemIndex = (await getItemIndex()).results; let list = snap.items.filter((it) => {
} catch { if (q && !loose(it.name).includes(q)) return false;
if (token === runToken) results.textContent = 'Item list unavailable offline.'; if (cat && it.category !== cat) return false;
return; return true;
} });
if (token !== runToken) return; list.sort((a, b) => (sort === 'id' ? a.id - b.id : a.name.localeCompare(b.name)));
clear(results);
} note.textContent = `${list.length} item${list.length === 1 ? '' : 's'}`;
const matches = itemIndex.filter((it) => loose(it.name).includes(q)).slice(0, 80); const shown = list.slice(0, 400);
if (!matches.length) return void results.append(el('p', { class: 'search__empty' }, 'No matches.')); for (const it of shown) {
for (const it of matches) { const icon = el('img', { class: 'search__item-icon', loading: 'lazy', alt: '', src: itemSprite(it.name) });
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/${idFromUrl(it.url)}` }, { class: 'search__row', href: `#/item/${it.id}` },
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 || '—'),
), ),
); );
} }
@ -211,12 +334,18 @@ 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 (!q) return; if (tab === 'moves') renderMoves(q, token);
(tab === 'moves' ? renderMoves : renderItems)(q, token); else if (tab === 'items') renderItems(q);
else renderAbilities(q);
} }
run(); run();

View File

@ -63,6 +63,15 @@ 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 }) => {
@ -93,7 +102,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), el('div', { class: 'settings__group' }, themeField, accentField, spriteField, shinyField, teamNavField),
el('h2', {}, 'Your data'), el('h2', {}, 'Your data'),
storageNote, storageNote,

283
src/views/TeamView.js Normal file
View File

@ -0,0 +1,283 @@
import { el, clear, onTeardown } from '../lib/dom.js';
import { loadSnapshot } from '../data/snapshot.js';
import { settings } from '../store/settings.js';
import { ui } from '../store/ui.js';
import { team, addToTeam, removeFromTeam, clearTeam, MAX_TEAM } from '../store/team.js';
import { TYPES, defenseVector, multiplier } from '../data/type-chart.js';
import { Sprite } from '../components/Sprite.js';
import { TypeChip } from '../components/TypeChip.js';
import { openPokemonPicker } from '../components/PokemonPicker.js';
const prettify = (s) => s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const STAT_ROWS = [
['hp', 'HP'],
['atk', 'Attack'],
['def', 'Defense'],
['spa', 'Sp. Atk'],
['spd', 'Sp. Def'],
['spe', 'Speed'],
];
const multClass = (m) =>
m === 0 ? 'm0' : m === 0.25 ? 'm025' : m === 0.5 ? 'm05' : m === 2 ? 'm2' : m === 4 ? 'm4' : 'm1';
const multText = (m) => (m === 0.25 ? '¼' : m === 0.5 ? '½' : m === 1 ? '' : `${m}×`);
export async function TeamView() {
const view = el('section', { class: 'view teamview' });
const snap = await loadSnapshot();
let mode = ui.get().teamMode === 'compare' ? 'compare' : 'coverage';
const seg = el(
'div',
{ class: 'seg' },
...[
['coverage', 'Coverage'],
['compare', 'Compare'],
].map(([id, label]) =>
el(
'button',
{
class: `seg__btn${id === mode ? ' is-active' : ''}`,
type: 'button',
onclick: () => {
mode = id;
ui.set({ teamMode: id });
[...seg.children].forEach((b, i) =>
b.classList.toggle('is-active', ['coverage', 'compare'][i] === id),
);
render();
},
},
label,
),
),
);
const lineup = el('div', { class: 'lineup' });
const body = el('div', { class: 'teamview__body' });
view.append(
el(
'header',
{ class: 'view__header' },
el('h1', {}, 'Team'),
el(
'p',
{},
`Up to ${MAX_TEAM} Pokémon — check weaknesses, coverage and stats. `,
el('a', { class: 'link', href: '#/natures' }, 'Natures reference'),
),
),
seg,
lineup,
body,
);
function members() {
return team.get().members.map((id) => snap.speciesById.get(id)).filter(Boolean);
}
function renderLineup() {
const mem = members();
clear(lineup);
const style = settings.get().spriteStyle;
for (const sp of mem) {
lineup.append(
el(
'div',
{ class: 'lineup__slot' },
el(
'button',
{
class: 'lineup__x',
type: 'button',
'aria-label': `Remove ${sp.name}`,
onclick: () => {
removeFromTeam(sp.id);
},
},
'✕',
),
el(
'a',
{ href: `#/pokemon/${sp.id}`, class: 'lineup__link' },
Sprite(sp.id, { style, alt: sp.name, size: 64 }),
el('span', { class: 'lineup__name' }, sp.name.replace(/-/g, ' ')),
),
),
);
}
if (mem.length < MAX_TEAM) {
lineup.append(
el(
'button',
{
class: 'lineup__add',
type: 'button',
onclick: () => openPokemonPicker(addToTeam),
},
'',
el('span', {}, 'Add'),
),
);
}
if (mem.length) {
lineup.append(
el(
'button',
{ class: 'lineup__clear', type: 'button', onclick: () => clearTeam() },
'Clear',
),
);
}
}
function render() {
renderLineup();
const mem = members();
clear(body);
if (!mem.length) {
body.append(el('p', { class: 'detail__muted' }, 'Add some Pokémon to get started.'));
return;
}
body.append(mode === 'coverage' ? coverage(mem) : compare(mem));
}
function coverage(mem) {
const gen = 9;
const vectors = mem.map((sp) => defenseVector(sp.types, gen));
// Defensive weakness table
const head = el(
'div',
{ class: 'cov__row cov__row--head' },
el('span', {}, ''),
...mem.map((sp) => el('span', { class: 'cov__mem' }, Sprite(sp.id, { size: 34, alt: sp.name }))),
el('span', { class: 'cov__sum' }, 'Weak'),
);
const rows = TYPES.map((t) => {
let weak = 0;
const cells = vectors.map((v) => {
const m = v[t] ?? 1;
if (m >= 2) weak += 1;
return el('span', { class: `cov__cell ${multClass(m)}` }, multText(m));
});
return el(
'div',
{ class: 'cov__row' },
el('span', { class: 'cov__type' }, TypeChip(t)),
...cells,
el('span', { class: `cov__sum${weak >= 2 ? ' is-bad' : ''}` }, weak || ''),
);
});
// Offensive coverage gaps: defending types no member hits super-effectively
const gaps = TYPES.filter((def) => {
if (gen < 6 && def === 'fairy') return false;
return !mem.some((sp) => sp.types.some((atk) => multiplier(atk, [def], gen) >= 2));
});
// Quick team stats
const avgBst = Math.round(mem.reduce((s, m) => s + (m.bst || 0), 0) / mem.length);
const fastest = mem.slice().sort((a, b) => (b.stats?.spe || 0) - (a.stats?.spe || 0))[0];
const bulkiest = mem
.slice()
.sort(
(a, b) =>
(b.stats?.hp || 0) + (b.stats?.def || 0) + (b.stats?.spd || 0) -
((a.stats?.hp || 0) + (a.stats?.def || 0) + (a.stats?.spd || 0)),
)[0];
return el(
'div',
{},
el('h2', { class: 'ppanel__sub' }, 'Type coverage — how each type hits your team'),
el('div', { class: 'cov' }, head, ...rows),
el('h2', { class: 'ppanel__sub' }, 'Offensive gaps (no super-effective STAB)'),
gaps.length
? el('div', { class: 'matchups__types' }, ...gaps.map(TypeChip))
: el('p', { class: 'detail__muted' }, 'Your team hits every type super-effectively. Nice.'),
el('h2', { class: 'ppanel__sub' }, 'At a glance'),
el(
'dl',
{ class: 'pfacts' },
fact('Average BST', String(avgBst)),
fact('Fastest', `${prettify(fastest.name)} (${fastest.stats?.spe ?? '?'} Spe)`),
fact(
'Bulkiest',
`${prettify(bulkiest.name)} (${
(bulkiest.stats?.hp || 0) + (bulkiest.stats?.def || 0) + (bulkiest.stats?.spd || 0)
} HP+Def+SpD)`,
),
),
);
}
function compare(mem) {
const maxOf = (key) => Math.max(...mem.map((m) => m.stats?.[key] ?? 0));
const row = (label, cells, cls = '') =>
el('div', { class: `cmp__row ${cls}` }, el('span', { class: 'cmp__label' }, label), ...cells);
return el(
'div',
{ class: 'cmp' },
row(
'',
mem.map((sp) =>
el(
'a',
{ class: 'cmp__mem', href: `#/pokemon/${sp.id}` },
Sprite(sp.id, { size: 44, alt: sp.name }),
el('span', {}, sp.name.replace(/-/g, ' ')),
),
),
'cmp__row--head',
),
row(
'Type',
mem.map((sp) => el('span', { class: 'cmp__cell' }, ...sp.types.map(TypeChip))),
),
...STAT_ROWS.map(([key, label]) => {
const mx = maxOf(key);
return row(
label,
mem.map((sp) => {
const v = sp.stats?.[key] ?? 0;
const bar = el('span', { class: 'cmp__bar' });
bar.style.width = `${(v / 200) * 100}%`;
return el(
'span',
{ class: `cmp__cell cmp__stat${v === mx ? ' is-max' : ''}` },
el('span', { class: 'cmp__val' }, String(v)),
el('span', { class: 'cmp__track' }, bar),
);
}),
);
}),
row(
'BST',
mem.map((sp) => {
const mx = Math.max(...mem.map((m) => m.bst || 0));
return el(
'span',
{ class: `cmp__cell cmp__stat${(sp.bst || 0) === mx ? ' is-max' : ''}` },
el('span', { class: 'cmp__val' }, String(sp.bst || 0)),
);
}),
'cmp__row--bst',
),
);
}
render();
const off = team.subscribe(render);
const offStyle = settings.subscribe(renderLineup);
onTeardown(view, () => {
off();
offStyle();
});
return view;
}
function fact(k, v) {
return el('div', { class: 'fact' }, el('dt', {}, k), el('dd', {}, v));
}