diff --git a/scripts/build-snapshot.mjs b/scripts/build-snapshot.mjs index 6b17808..82781d6 100644 --- a/scripts/build-snapshot.mjs +++ b/scripts/build-snapshot.mjs @@ -2,13 +2,15 @@ /** * Build a compact, offline-first data snapshot from PokéAPI. * - * The app needs three things to work fully offline before any lazy detail - * fetch: the species list (id, name, generation, types), the regional - * Pokédex lists (which species, in what order, with regional numbers), and - * the version groups (games) with their associated Pokédexes. + * The app runs its list, search, sort, filters and game switching entirely + * off this file — no network until you open a detail page. It carries, per + * species: name, generation, current + past typings, the six base stats and + * their total, height, weight, base experience, and species flags + * (legendary / mythical / baby, capture rate, growth rate, colour, egg + * groups, gender rate). Plus every regional Pokédex and every version group. * - * Everything richer than that — stats, abilities, flavor text, evolution — - * is fetched on demand by the app and cached by the service worker. + * Richer detail (abilities, moves, flavour text, evolution, encounters) is + * fetched on demand by the app and cached by the service worker. * * Output: src/data/snapshot.json (imported by src/data/snapshot.js) * Usage: node scripts/build-snapshot.mjs [--force] @@ -22,19 +24,18 @@ const OUT = resolve(__dirname, '../src/data/snapshot.json'); const BASE = 'https://pokeapi.co/api/v2'; const MAX_AGE_DAYS = 30; const FORCE = process.argv.includes('--force'); -const CONCURRENCY = 8; +const CONCURRENCY = 16; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop()); -const pretty = (k) => - k.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +const pretty = (k) => k.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); const _cache = new Map(); async function api(path) { if (_cache.has(path)) return _cache.get(path); const url = path.startsWith('http') ? path : `${BASE}/${path}`; let lastErr; - for (let attempt = 0; attempt < 4; attempt++) { + for (let attempt = 0; attempt < 5; attempt++) { try { const res = await fetch(url); if (res.ok) { @@ -55,13 +56,19 @@ async function api(path) { async function mapLimit(items, limit, fn) { const out = new Array(items.length); let i = 0; - const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { - while (i < items.length) { + let done = 0; + const total = items.length; + const workers = Array.from({ length: Math.min(limit, total) }, async () => { + while (i < total) { const idx = i++; out[idx] = await fn(items[idx], idx); + if (++done % 200 === 0 || done === total) { + process.stdout.write(`\r … ${done}/${total} species`); + } } }); await Promise.all(workers); + if (total) process.stdout.write('\n'); return out; } @@ -85,71 +92,29 @@ async function main() { console.log('Building snapshot from PokéAPI …'); const t0 = Date.now(); - // ---- Types: build pokemonName -> [type, ...] ----------------------------- + // ---- Type name list ------------------------------------------------ const typeIndex = await api('type?limit=100'); - const usableTypes = typeIndex.results + const types = typeIndex.results .map((r) => r.name) .filter((n) => !['unknown', 'shadow', 'stellar'].includes(n)); - const typesByPokemon = new Map(); - await mapLimit(usableTypes, CONCURRENCY, async (t) => { - const data = await api(`type/${t}`); - for (const { slot, pokemon } of data.pokemon) { - const arr = typesByPokemon.get(pokemon.name) || []; - arr.push({ slot, type: t }); - typesByPokemon.set(pokemon.name, arr); - } - }); - const typesFor = (name) => - (typesByPokemon.get(name) || []) - .sort((a, b) => a.slot - b.slot) - .map((x) => x.type); - const typesForSpecies = (name) => { - const direct = typesFor(name); - if (direct.length) return direct; - // Fall back to a default form variant, e.g. "deoxys" -> "deoxys-normal". - for (const key of typesByPokemon.keys()) { - if (key === name || key.startsWith(`${name}-`)) { - const t = typesFor(key); - if (t.length) return t; - } - } - return []; - }; - - // ---- Generations: species -> { name, generation } ---------------------- + // ---- Generations (for display names / regions) ------------------ const genIndex = await api('generation?limit=50'); const generations = []; - const speciesMeta = new Map(); - await mapLimit(genIndex.results, CONCURRENCY, async (g) => { + await mapLimit(genIndex.results, 8, async (g) => { const data = await api(`generation/${g.name}`); generations.push({ id: data.id, name: pretty(data.main_region?.name || g.name), region: data.main_region?.name || null, }); - for (const sp of data.pokemon_species) { - const id = idFromUrl(sp.url); - speciesMeta.set(id, { id, name: sp.name, generation: data.id }); - } }); generations.sort((a, b) => a.id - b.id); - const speciesIds = [...speciesMeta.keys()].sort((a, b) => a - b); - const species = speciesIds.map((id) => { - const m = speciesMeta.get(id); - return { - id, - name: m.name, - generation: m.generation, - types: typesForSpecies(m.name), - }; - }); - - // ---- Pokédexes -------------------------------------------------------- + // ---- Pokédexes ---------------------------------------------------- const dexIndex = await api('pokedex?limit=100'); const pokedexes = []; - await mapLimit(dexIndex.results, CONCURRENCY, async (d) => { + await mapLimit(dexIndex.results, 8, async (d) => { const data = await api(`pokedex/${d.name}`); pokedexes.push({ key: data.name, @@ -164,10 +129,10 @@ async function main() { }); pokedexes.sort((a, b) => a.key.localeCompare(b.key)); - // ---- Version groups (the "games") ----------------------------------- + // ---- Version groups (the "games") ----------------------------- const vgIndex = await api('version-group?limit=100'); const versionGroups = []; - await mapLimit(vgIndex.results, CONCURRENCY, async (v) => { + await mapLimit(vgIndex.results, 8, async (v) => { const data = await api(`version-group/${v.name}`); versionGroups.push({ key: data.name, @@ -181,7 +146,57 @@ async function main() { (a, b) => a.generation - b.generation || a.key.localeCompare(b.key), ); - // ---- Write ---------------------------------------------------------- + // ---- Species: the data-rich part ----------------------------- + const speciesIndex = await api('pokemon-species?limit=100000'); + const ids = speciesIndex.results + .map((r) => idFromUrl(r.url)) + .sort((a, b) => a - b); + + console.log(`Fetching ${ids.length} species (stats, typings, flags) …`); + const species = await mapLimit(ids, CONCURRENCY, async (id) => { + const [pk, sp] = await Promise.all([ + api(`pokemon/${id}`), + api(`pokemon-species/${id}`), + ]); + const s = Object.fromEntries(pk.stats.map((x) => [x.stat.name, x.base_stat])); + const stats = { + hp: s.hp ?? 0, + atk: s.attack ?? 0, + def: s.defense ?? 0, + spa: s['special-attack'] ?? 0, + spd: s['special-defense'] ?? 0, + spe: s.speed ?? 0, + }; + return { + id, + name: sp.name, + generation: idFromUrl(sp.generation.url), + types: pk.types + .slice() + .sort((a, b) => a.slot - b.slot) + .map((t) => t.type.name), + pastTypes: (pk.past_types || []).map((p) => ({ + gen: idFromUrl(p.generation.url), + types: p.types.slice().sort((a, b) => a.slot - b.slot).map((t) => t.type.name), + })), + stats, + bst: stats.hp + stats.atk + stats.def + stats.spa + stats.spd + stats.spe, + height: pk.height, + weight: pk.weight, + baseExp: pk.base_experience ?? null, + isLegendary: sp.is_legendary, + isMythical: sp.is_mythical, + isBaby: sp.is_baby, + captureRate: sp.capture_rate, + baseHappiness: sp.base_happiness, + growthRate: sp.growth_rate?.name || null, + color: sp.color?.name || null, + eggGroups: sp.egg_groups.map((g) => g.name), + genderRate: sp.gender_rate, + }; + }); + + // ---- Write ---------------------------------------------------- const snapshot = { meta: { generatedAt: new Date().toISOString(), @@ -192,7 +207,7 @@ async function main() { versionGroups: versionGroups.length, }, }, - types: usableTypes, + types, generations, species, pokedexes, @@ -200,14 +215,12 @@ async function main() { }; await mkdir(dirname(OUT), { recursive: true }); - await writeFile(OUT, JSON.stringify(snapshot)); - const kb = (JSON.stringify(snapshot).length / 1024).toFixed(0); + const body = JSON.stringify(snapshot); + await writeFile(OUT, body); console.log( `Wrote ${OUT} — ${species.length} species, ${pokedexes.length} pokédexes, ` + - `${versionGroups.length} version groups (${kb} KB) in ${( - (Date.now() - t0) / - 1000 - ).toFixed(1)}s`, + `${versionGroups.length} version groups (${(body.length / 1024).toFixed(0)} KB) in ` + + `${((Date.now() - t0) / 1000).toFixed(1)}s`, ); } diff --git a/src/components/Card.js b/src/components/Card.js index 8a71245..d72bc5d 100644 --- a/src/components/Card.js +++ b/src/components/Card.js @@ -9,7 +9,7 @@ import { typeHex } from '../lib/type-color.js'; * gradient, a type-coloured spotlight behind an oversized sprite that lifts * above the card, a number chip, and a big ghost number in the corner. */ -export function Card(species, number, { spriteStyle = 'official', versionGroup, boxed = false } = {}) { +export function Card(species, number, { spriteStyle = 'official', versionGroup, boxed = false, metric = null } = {}) { const state = entry(species.id); const mainType = (species.types || [])[0] || 'normal'; const num = String(number ?? species.id).padStart(3, '0'); @@ -53,6 +53,7 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup, el('span', { class: 'card__num' }, `#${num}`), el('span', { class: 'card__name' }, species.name.replace(/-/g, ' ')), el('span', { class: 'card__types' }, ...(species.types || []).map(TypeChip)), + metric ? el('span', { class: 'card__metric' }, metric) : null, ), ); diff --git a/src/store/ui.js b/src/store/ui.js index 0764cad..1e38d85 100644 --- a/src/store/ui.js +++ b/src/store/ui.js @@ -8,4 +8,7 @@ export const ui = createStore('pdx.ui', { detailTab: 'about', searchQuery: '', searchScroll: 0, + sort: 'dex', + sortDesc: false, + filter: 'all', }); diff --git a/src/styles/layout.css b/src/styles/layout.css index 3c21681..0bef994 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -355,6 +355,42 @@ opacity: 0.7; } +.feed-sort { + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; +} +.feed-sort__label { + font-size: 0.78rem; + font-weight: 600; + color: var(--text-dim); +} +.feed-sort__select { + flex: 1; + max-width: 220px; + padding: 7px 12px; + border: 1.5px solid var(--border); + border-radius: 999px; + background: var(--surface); + color: var(--text); + font: inherit; + font-size: 0.84rem; + font-weight: 600; + cursor: pointer; +} +.feed-sort__dir { + width: 34px; + height: 34px; + flex: none; + border: 1.5px solid var(--border); + border-radius: 50%; + background: var(--surface); + color: var(--text); + font-size: 1rem; + cursor: pointer; +} + .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr)); @@ -497,6 +533,17 @@ flex-wrap: wrap; margin-top: 2px; } +.card__metric { + margin-top: 6px; + align-self: flex-start; + padding: 2px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--type-main) 24%, var(--surface-2)); + color: var(--text); + font-size: 0.72rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} .card__caught { position: absolute; top: 10px; @@ -678,8 +725,14 @@ font-size: 0.9rem; } .phero__num { + flex: 1; + min-width: 0; + text-align: center; opacity: 0.85; text-transform: capitalize; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .phero__head { margin-top: 14px; @@ -754,19 +807,23 @@ .phero__nav:hover { background: rgba(255, 255, 255, 0.3); } +.phero__actions { + display: flex; + align-items: center; + gap: 8px; + flex: none; +} .phero__shiny { - position: absolute; - right: 20px; - bottom: 108px; border: none; border-radius: 999px; - padding: 6px 14px; + padding: 6px 12px; font: inherit; - font-size: 0.75rem; + font-size: 0.74rem; font-weight: 600; cursor: pointer; color: var(--ink); background: rgba(255, 255, 255, 0.18); + white-space: nowrap; } .phero__shiny.is-on { background: #fff; @@ -902,7 +959,6 @@ display: block; font-size: 0.76rem; color: var(--text-dim); - text-transform: capitalize; } .flavor__all { margin-top: 12px; diff --git a/src/views/DexGrid.js b/src/views/DexGrid.js index 0b0524c..c841710 100644 --- a/src/views/DexGrid.js +++ b/src/views/DexGrid.js @@ -2,6 +2,7 @@ import { el, clear, onTeardown } from '../lib/dom.js'; import { loadSnapshot } from '../data/snapshot.js'; import { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js'; import { settings } from '../store/settings.js'; +import { ui } from '../store/ui.js'; import { selection, stats } from '../store/selection.js'; import { Card } from '../components/Card.js'; import { ProgressRing } from '../components/ProgressRing.js'; @@ -12,18 +13,69 @@ const FILTERS = [ { key: 'caught', label: 'Caught', test: (e) => e.caught }, { key: 'missing', label: 'Missing', test: (e) => !e.caught }, { key: 'favorite', label: 'Favorites', test: (e) => e.favorite }, + { key: 'legendary', label: 'Legendary', test: (e, sp) => sp.isLegendary || sp.isMythical }, ]; +const SORTS = [ + { key: 'dex', label: 'Dex number', desc: false }, + { key: 'name', label: 'Name (A–Z)', desc: false }, + { key: 'bst', label: 'Base stat total', desc: true }, + { key: 'hp', label: 'HP', desc: true }, + { key: 'atk', label: 'Attack', desc: true }, + { key: 'def', label: 'Defense', desc: true }, + { key: 'spa', label: 'Sp. Attack', desc: true }, + { key: 'spd', label: 'Sp. Defense', desc: true }, + { key: 'spe', label: 'Speed', desc: true }, + { key: 'height', label: 'Height', desc: true }, + { key: 'weight', label: 'Weight', desc: true }, + { key: 'baseExp', label: 'Base EXP', desc: true }, + { key: 'catchrate', label: 'Catch rate', desc: true }, + { key: 'caught', label: 'Recently caught', desc: true }, +]; +const STAT_SHORT = { hp: 'HP', atk: 'Atk', def: 'Def', spa: 'SpA', spd: 'SpD', spe: 'Spe' }; + const SEARCH_ICON = ''; +function sortValue(row, key) { + const sp = row.species; + switch (key) { + case 'dex': return row.number ?? sp.id; + case 'name': return sp.name; + case 'bst': return sp.bst ?? 0; + case 'hp': case 'atk': case 'def': case 'spa': case 'spd': case 'spe': + return sp.stats?.[key] ?? 0; + case 'height': return sp.height ?? 0; + case 'weight': return sp.weight ?? 0; + case 'baseExp': return sp.baseExp ?? 0; + case 'catchrate': return sp.captureRate ?? 0; + case 'caught': return selection.get().pokemon[sp.id]?.updatedAt || ''; + default: return 0; + } +} + +function metricLabel(key, sp) { + switch (key) { + case 'bst': return `BST ${sp.bst}`; + case 'hp': case 'atk': case 'def': case 'spa': case 'spd': case 'spe': + return `${STAT_SHORT[key]} ${sp.stats?.[key] ?? 0}`; + case 'height': return `${((sp.height ?? 0) / 10).toFixed(1)} m`; + case 'weight': return `${((sp.weight ?? 0) / 10).toFixed(1)} kg`; + case 'baseExp': return sp.baseExp != null ? `${sp.baseExp} EXP` : null; + case 'catchrate': return `Catch ${sp.captureRate ?? '?'}`; + default: return null; + } +} + export async function DexGrid() { const view = el('section', { class: 'view dexgrid' }); const snap = await loadSnapshot(); const grid = el('div', { class: 'grid' }); let query = ''; - let filterKey = 'all'; + let filterKey = ui.get().filter || 'all'; + let sortKey = ui.get().sort || 'dex'; + let sortDesc = !!ui.get().sortDesc; let rows = []; let ids = []; @@ -53,10 +105,11 @@ export async function DexGrid() { const btn = el( 'button', { - class: 'feed-chip', + class: `feed-chip${f.key === filterKey ? ' is-active' : ''}`, type: 'button', onclick: () => { filterKey = f.key; + ui.set({ filter: f.key }); chipEls.forEach((c, i) => c.btn.classList.toggle('is-active', FILTERS[i].key === f.key)); paintGrid(); }, @@ -67,7 +120,37 @@ export async function DexGrid() { filterBar.append(btn); return { btn, count }; }); - chipEls[0].btn.classList.add('is-active'); + + const sortSelect = el( + 'select', + { + class: 'feed-sort__select', + onchange: (e) => { + sortKey = e.target.value; + sortDesc = SORTS.find((s) => s.key === sortKey).desc; + ui.set({ sort: sortKey, sortDesc }); + syncDirBtn(); + paintGrid(); + }, + }, + ...SORTS.map((s) => el('option', { value: s.key, selected: s.key === sortKey }, s.label)), + ); + const dirBtn = el('button', { + class: 'feed-sort__dir', + type: 'button', + title: 'Reverse order', + onclick: () => { + sortDesc = !sortDesc; + ui.set({ sortDesc }); + syncDirBtn(); + paintGrid(); + }, + }); + function syncDirBtn() { + dirBtn.textContent = sortDesc ? '↓' : '↑'; + dirBtn.setAttribute('aria-label', sortDesc ? 'Descending' : 'Ascending'); + } + syncDirBtn(); view.append( el( @@ -86,6 +169,13 @@ export async function DexGrid() { search, ), filterBar, + el( + 'div', + { class: 'feed-sort' }, + el('span', { class: 'feed-sort__label' }, 'Sort'), + sortSelect, + dirBtn, + ), ), grid, ); @@ -94,13 +184,14 @@ export async function DexGrid() { const p = selection.get().pokemon; let caught = 0; let favorite = 0; - for (const id of ids) { - const e = p[id]; - if (!e) continue; - if (e.caught) caught++; - if (e.favorite) favorite++; + let legendary = 0; + for (const { species } of rows) { + const e = p[species.id]; + if (e?.caught) caught++; + if (e?.favorite) favorite++; + if (species.isLegendary || species.isMythical) legendary++; } - return { all: ids.length, caught, missing: ids.length - caught, favorite }; + return { all: rows.length, caught, missing: rows.length - caught, favorite, legendary }; } function refreshMeta() { @@ -114,38 +205,49 @@ export async function DexGrid() { function paintGrid() { const st = settings.get(); const pokemonState = selection.get().pokemon; - // Gen 1–2 game-era sprites have opaque white backgrounds — frame them. const boxed = st.spriteStyle === 'game' && (snap.versionGroupByKey.get(st.versionGroup)?.generation ?? 9) <= 2; const filterTest = FILTERS.find((f) => f.key === filterKey).test; - const frag = document.createDocumentFragment(); - let shown = 0; - for (const { species, number } of rows) { + let list = rows.filter(({ species, number }) => { if ( query && !species.name.includes(query) && String(number) !== query && String(species.id) !== query ) { - continue; + return false; } const e = pokemonState[species.id] || { seen: false, caught: false, favorite: false }; - if (!filterTest(e)) continue; + return filterTest(e, species); + }); + + const dir = sortDesc ? -1 : 1; + list = list + .map((row) => ({ row, v: sortValue(row, sortKey) })) + .sort((a, b) => { + if (a.v < b.v) return -dir; + if (a.v > b.v) return dir; + return (a.row.number ?? a.row.species.id) - (b.row.number ?? b.row.species.id); + }) + .map((x) => x.row); + + const frag = document.createDocumentFragment(); + list.forEach(({ species, number }, i) => { const card = Card(species, number, { spriteStyle: st.spriteStyle, versionGroup: st.versionGroup, boxed, + metric: metricLabel(sortKey, species), }); - if (shown < 30) card.style.setProperty('--i', String(shown)); + if (i < 30) card.style.setProperty('--i', String(i)); frag.append(card); - shown++; - } + }); clear(grid); grid.append( - shown === 0 + list.length === 0 ? el('p', { class: 'grid__empty' }, 'Nothing matches those filters.') : frag, ); @@ -169,7 +271,8 @@ export async function DexGrid() { const offSettings = settings.subscribe(rebuild); const offSelection = selection.subscribe(() => { refreshMeta(); - if (filterKey !== 'all') paintGrid(); + if (filterKey !== 'all' && filterKey !== 'legendary') paintGrid(); + else if (sortKey === 'caught') paintGrid(); }); onTeardown(view, () => { offSettings(); diff --git a/src/views/PokemonDetail.js b/src/views/PokemonDetail.js index c0fa26c..904146f 100644 --- a/src/views/PokemonDetail.js +++ b/src/views/PokemonDetail.js @@ -300,7 +300,7 @@ export async function PokemonDetail(nationalId) { { class: 'phero__top' }, el('a', { class: 'phero__back', href: '#/' }, '‹ Dex'), el('span', { class: 'phero__num' }, numLabel), - favBtn, + el('div', { class: 'phero__actions' }, shinyBtn, favBtn), ), el( 'div', @@ -319,7 +319,6 @@ export async function PokemonDetail(nationalId) { ? el('a', { class: 'phero__nav phero__nav--next', href: `#/pokemon/${next.species.id}`, title: next.species.name }, '›') : el('span', { class: 'phero__nav' }), ), - shinyBtn, ), el( 'div',