diff --git a/src/components/Card.js b/src/components/Card.js index 82166a0..8c9d3ea 100644 --- a/src/components/Card.js +++ b/src/components/Card.js @@ -3,15 +3,17 @@ import { Sprite } from './Sprite.js'; import { TypeChip } from './TypeChip.js'; import { entry, toggle } from '../store/selection.js'; import { typeHex } from '../lib/type-color.js'; +import { typesForGen } from '../lib/type-resolve.js'; /** * One Pokémon in the dex feed. Tinted by its primary type: a soft top-down * gradient, a type-coloured spotlight behind an oversized sprite that lifts * above the card, a number chip, and a big ghost number in the corner. */ -export function Card(species, number, { spriteStyle = 'official', versionGroup, boxed = false, metric = null } = {}) { +export function Card(species, number, { spriteStyle = 'official', versionGroup, gen = 9, boxed = false, metric = null } = {}) { const state = entry(species.id); - const mainType = (species.types || [])[0] || 'normal'; + const types = typesForGen(species, gen); + const mainType = types[0] || 'normal'; const num = String(number ?? species.id).padStart(3, '0'); const data = { type: mainType, sprite: spriteStyle }; if (boxed) data.boxed = '1'; @@ -58,7 +60,7 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup, { class: 'card__body' }, el('span', { class: 'card__num' }, `#${num}`), el('span', { class: 'card__name' }, species.name.replace(/-/g, ' ')), - el('span', { class: 'card__types' }, ...(species.types || []).map(TypeChip)), + el('span', { class: 'card__types' }, ...types.map(TypeChip)), metric ? el('span', { class: 'card__metric' }, metric) : null, ), ); diff --git a/src/data/type-chart.js b/src/data/type-chart.js index 21495b8..13fa2b0 100644 --- a/src/data/type-chart.js +++ b/src/data/type-chart.js @@ -64,6 +64,23 @@ export function multiplier(attacking, defTypes, gen = 9) { return m; } +/** + * What a Pokémon's STAB types hit, from the attacker's side. + * Returns { strong: [types any STAB is 2x+ against], walls: [types that + * resist every one of its STAB types] }. + */ +export function offensiveSummary(atkTypes, gen = 9) { + const strong = []; + const walls = []; + const defs = gen < 6 ? TYPES.filter((t) => t !== 'fairy') : TYPES; + for (const def of defs) { + const mults = atkTypes.map((atk) => multiplier(atk, [def], gen)); + if (Math.max(...mults) >= 2) strong.push(def); + else if (mults.every((m) => m <= 0.5)) walls.push(def); + } + return { strong, walls }; +} + /** * Defensive matchups for a Pokémon, bucketed by multiplier. * Returns { '4': [...types], '2': [...], '0.5': [...], '0.25': [...], '0': [...] } diff --git a/src/lib/type-resolve.js b/src/lib/type-resolve.js new file mode 100644 index 0000000..77d5776 --- /dev/null +++ b/src/lib/type-resolve.js @@ -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) || []; +} diff --git a/src/store/ui.js b/src/store/ui.js index ab8c17e..ea17519 100644 --- a/src/store/ui.js +++ b/src/store/ui.js @@ -13,4 +13,7 @@ export const ui = createStore('pdx.ui', { sort: 'dex', sortDesc: false, filter: 'all', + filterType: '', + filterGen: 0, + recent: [], }); diff --git a/src/styles/layout.css b/src/styles/layout.css index 6d0cb49..9d404a8 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -1825,3 +1825,77 @@ 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; +} diff --git a/src/sw.js b/src/sw.js index 84a15b7..415586b 100644 --- a/src/sw.js +++ b/src/sw.js @@ -34,7 +34,7 @@ registerRoute( registerRoute( ({ url }) => url.origin === 'https://raw.githubusercontent.com' && - url.pathname.includes('/PokeAPI/sprites/'), + (url.pathname.includes('/PokeAPI/sprites/') || url.pathname.includes('/PokeAPI/cries/')), new CacheFirst({ cacheName: 'pokeapi-img', plugins: [ diff --git a/src/views/DexGrid.js b/src/views/DexGrid.js index d8db59d..84331e2 100644 --- a/src/views/DexGrid.js +++ b/src/views/DexGrid.js @@ -6,6 +6,13 @@ import { ui } from '../store/ui.js'; import { selection, stats } from '../store/selection.js'; import { Card } from '../components/Card.js'; import { ProgressRing } from '../components/ProgressRing.js'; +import { Sprite } from '../components/Sprite.js'; +import { typesForGen } from '../lib/type-resolve.js'; + +const TYPES = [ + 'normal', 'fire', 'water', 'electric', 'grass', 'ice', 'fighting', 'poison', + 'ground', 'flying', 'psychic', 'bug', 'rock', 'ghost', 'dragon', 'dark', 'steel', 'fairy', +]; const FILTERS = [ { key: 'all', label: 'All', test: () => true }, @@ -73,10 +80,13 @@ export async function DexGrid() { const grid = el('div', { class: 'grid' }); let query = ''; let filterKey = ui.get().filter || 'all'; + let filterType = ui.get().filterType || ''; + let filterGen = Number(ui.get().filterGen) || 0; let sortKey = ui.get().sort || 'dex'; let sortDesc = !!ui.get().sortDesc; let rows = []; let ids = []; + let lastList = []; const ring = ProgressRing(); @@ -151,6 +161,52 @@ export async function DexGrid() { } syncDirBtn(); + const typeSelect = el( + 'select', + { + class: 'feed-sort__select', + onchange: (e) => { + filterType = e.target.value; + ui.set({ filterType }); + paintGrid(); + resetScroll(); + }, + }, + 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(); + }, + }, + el('option', { value: '0' }, 'Any gen'), + ...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((g) => + el('option', { value: String(g), selected: g === filterGen }, `Gen ${g}`), + ), + ); + const randomBtn = el( + 'button', + { + class: 'feed-sort__dir', + type: 'button', + title: 'Random Pokémon', + onclick: () => { + if (lastList.length) { + const pick = lastList[Math.floor(Math.random() * lastList.length)]; + location.hash = `#/pokemon/${pick.species.id}`; + } + }, + }, + '🎲', + ); + // Changing the list order/contents makes the old scroll offset meaningless. function resetScroll() { ui.set({ feedScroll: 0 }); @@ -175,6 +231,29 @@ export async function DexGrid() { if (document.fonts && document.fonts.ready) document.fonts.ready.then(go); } + const recentStrip = el('div', { class: 'recent' }); + function renderRecent() { + const rec = (ui.get().recent || []) + .map((id) => snap.speciesById.get(id)) + .filter(Boolean) + .slice(0, 12); + clear(recentStrip); + recentStrip.hidden = rec.length === 0; + if (!rec.length) return; + recentStrip.append(el('span', { class: 'recent__label' }, 'Recent')); + const style = settings.get().spriteStyle; + for (const sp of rec) { + recentStrip.append( + el( + 'a', + { class: 'recent__item', href: `#/pokemon/${sp.id}`, title: sp.name.replace(/-/g, ' ') }, + Sprite(sp.id, { style, alt: sp.name, size: 44 }), + ), + ); + } + } + renderRecent(); + view.append( el( 'header', @@ -199,7 +278,9 @@ export async function DexGrid() { sortSelect, dirBtn, ), + el('div', { class: 'feed-sort feed-filters' }, typeSelect, genSelect, randomBtn), ), + recentStrip, grid, ); @@ -228,9 +309,8 @@ export async function DexGrid() { function paintGrid() { const st = settings.get(); const pokemonState = selection.get().pokemon; - const boxed = - st.spriteStyle === 'game' && - (snap.versionGroupByKey.get(st.versionGroup)?.generation ?? 9) <= 2; + const gen = snap.versionGroupByKey.get(st.versionGroup)?.generation ?? 9; + const boxed = st.spriteStyle === 'game' && gen <= 2; const filterTest = FILTERS.find((f) => f.key === filterKey).test; let list = rows.filter(({ species, number }) => { @@ -242,6 +322,8 @@ export async function DexGrid() { ) { return false; } + if (filterGen && species.generation !== filterGen) return false; + if (filterType && !typesForGen(species, gen).includes(filterType)) return false; const e = pokemonState[species.id] || { seen: false, caught: false, favorite: false }; return filterTest(e, species); }); @@ -255,12 +337,14 @@ export async function DexGrid() { return (a.row.number ?? a.row.species.id) - (b.row.number ?? b.row.species.id); }) .map((x) => x.row); + lastList = list; const frag = document.createDocumentFragment(); list.forEach(({ species, number }, i) => { const card = Card(species, number, { spriteStyle: st.spriteStyle, versionGroup: st.versionGroup, + gen, boxed, metric: metricLabel(sortKey, species), }); diff --git a/src/views/PokemonDetail.js b/src/views/PokemonDetail.js index 0c34735..af02585 100644 --- a/src/views/PokemonDetail.js +++ b/src/views/PokemonDetail.js @@ -15,9 +15,47 @@ import { TypeMatchups } from '../components/TypeMatchups.js'; import { Locations } from '../components/Locations.js'; import { FlavorText } from '../components/FlavorText.js'; import { typeHex } from '../lib/type-color.js'; +import { offensiveSummary } from '../data/type-chart.js'; const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop()); +const STAT_LABEL = { + hp: 'HP', + attack: 'Atk', + defense: 'Def', + 'special-attack': 'SpA', + 'special-defense': 'SpD', + speed: 'Spe', +}; +const GROWTH_EXP = { + erratic: 600000, + fast: 800000, + 'medium-fast': 1000000, + 'medium-slow': 1059860, + slow: 1250000, + fluctuating: 1640000, +}; + +/** "87.5% ♀ 12.5% ♂" or "Genderless", plus a two-tone bar. */ +function genderCell(rate) { + if (rate == null || rate < 0) return el('dd', {}, 'Genderless'); + const female = (rate / 8) * 100; + const bar = el('div', { class: 'gender-bar' }); + bar.style.setProperty('--f', `${female}%`); + return el( + 'dd', + { class: 'gender' }, + bar, + el( + 'span', + {}, + female < 100 ? `${(100 - female).toFixed(female % 12.5 ? 1 : 0)}% ♂` : '', + female > 0 && female < 100 ? ' · ' : '', + female > 0 ? `${female.toFixed(female % 12.5 ? 1 : 0)}% ♀` : '', + ), + ); +} + /** A Pokémon's typing as it was in the selected game's generation. */ function typesForGeneration(pokemon, gen) { const past = (pokemon.past_types || []) @@ -81,6 +119,11 @@ export async function PokemonDetail(nationalId) { return view; } + // Recently-viewed history for the feed strip (most recent first, capped). + ui.set((s) => ({ + recent: [nationalId, ...(s.recent || []).filter((x) => x !== nationalId)].slice(0, 12), + })); + const types = typesForGeneration(pokemon, vgGen); const mainType = types[0]; view.style.setProperty('--type-main', typeHex(mainType)); @@ -107,6 +150,25 @@ export async function PokemonDetail(nationalId) { }, }) : null; + + const cryUrl = pokemon.cries?.latest || pokemon.cries?.legacy; + const cryBtn = cryUrl + ? el( + 'button', + { + class: 'phero__shiny phero__cry', + type: 'button', + title: 'Play cry', + 'aria-label': 'Play cry', + onclick: () => { + const a = new Audio(cryUrl); + a.volume = 0.45; + a.play().catch(() => {}); + }, + }, + '►', + ) + : null; function paintArt() { artHolder.replaceChildren( Sprite(nationalId, { @@ -165,6 +227,18 @@ export async function PokemonDetail(nationalId) { .filter((a) => !a.is_hidden || vgGen >= 5) .map((a) => prettify(a.ability.name) + (a.is_hidden ? ' (hidden)' : '')); + const evYield = + pokemon.stats + .filter((s) => s.effort > 0) + .map((s) => `${s.effort} ${STAT_LABEL[s.stat.name] || s.stat.name}`) + .join(', ') || '—'; + const heldItems = + pokemon.held_items?.map((h) => prettify(h.item.name)).join(', ') || '—'; + const eggGroups = species.egg_groups.map((g) => prettify(g.name)); + const hatch = species.hatch_counter; + const growth = species.growth_rate?.name; + const rate = species.capture_rate; + const aboutPanel = el( 'div', { class: 'ppanel' }, @@ -183,6 +257,34 @@ export async function PokemonDetail(nationalId) { ) : null, ), + el('h3', { class: 'ppanel__sub' }, 'Training'), + el( + 'dl', + { class: 'pfacts' }, + fact('EV yield', evYield), + fact('Base EXP', pokemon.base_experience ?? '—'), + fact('Catch rate', rate != null ? `${rate} / 255 (~${Math.round((rate / 255) * 100)}% max)` : '—'), + fact('Base friendship', species.base_happiness ?? '—'), + fact( + 'Growth rate', + growth ? `${prettify(growth)}${GROWTH_EXP[growth] ? ` · ${GROWTH_EXP[growth].toLocaleString()} EXP` : ''}` : '—', + ), + fact('Held items', heldItems), + ), + el('h3', { class: 'ppanel__sub' }, 'Breeding'), + el( + 'dl', + { class: 'pfacts' }, + el('div', { class: 'fact' }, el('dt', {}, 'Gender'), genderCell(species.gender_rate)), + fact( + 'Egg groups', + eggGroups.includes('Undiscovered') ? "Undiscovered (can't breed)" : eggGroups.join(', ') || '—', + ), + fact( + 'Egg cycles', + hatch != null ? `${hatch} (~${((hatch + 1) * 255).toLocaleString()} steps)` : '—', + ), + ), ); // ---- Stats panel --------------------------------------------- @@ -203,6 +305,7 @@ export async function PokemonDetail(nationalId) { ), el('h3', { class: 'ppanel__sub' }, 'Type matchups (defending)'), TypeMatchups(types, vgGen), + offensiveBlock(types, vgGen), ); // ---- Evolution panel -------------------------------------- @@ -307,7 +410,7 @@ export async function PokemonDetail(nationalId) { { class: 'phero__top' }, el('a', { class: 'phero__back', href: '#/' }, '‹ Dex'), el('span', { class: 'phero__num' }, numLabel), - el('div', { class: 'phero__actions' }, shinyBtn, favBtn), + el('div', { class: 'phero__actions' }, cryBtn, shinyBtn, favBtn), ), el( 'div', @@ -372,3 +475,36 @@ async function loadEvolution(species, st, snap, vgGen) { function fact(label, value) { return el('div', { class: 'fact' }, el('dt', {}, label), el('dd', {}, value)); } + +/** STAB offensive coverage summary for the Stats tab. */ +function offensiveBlock(types, gen) { + const { strong, walls } = offensiveSummary(types, gen); + return el( + 'div', + {}, + el('h3', { class: 'ppanel__sub' }, 'STAB coverage (attacking)'), + el( + 'div', + { class: 'matchups' }, + strong.length + ? el( + 'div', + { class: 'matchups__row matchups__row--x2' }, + el('span', { class: 'matchups__label' }, 'Strong vs'), + el('span', { class: 'matchups__types' }, ...strong.map(TypeChip)), + ) + : null, + walls.length + ? el( + 'div', + { class: 'matchups__row matchups__row--half' }, + el('span', { class: 'matchups__label' }, 'Walled by'), + el('span', { class: 'matchups__types' }, ...walls.map(TypeChip)), + ) + : null, + !strong.length && !walls.length + ? el('p', { class: 'detail__muted' }, 'No notable STAB coverage.') + : null, + ), + ); +}