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>
This commit is contained in:
chris 2026-08-28 10:06:36 -04:00
parent 8ebdb9d197
commit 2e4572428a
8 changed files with 337 additions and 8 deletions

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,7 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup,
{ class: 'card__body' }, { class: 'card__body' },
el('span', { class: 'card__num' }, `#${num}`), el('span', { class: 'card__num' }, `#${num}`),
el('span', { class: 'card__name' }, species.name.replace(/-/g, ' ')), el('span', { class: 'card__name' }, species.name.replace(/-/g, ' ')),
el('span', { class: 'card__types' }, ...(species.types || []).map(TypeChip)), el('span', { class: 'card__types' }, ...types.map(TypeChip)),
metric ? el('span', { class: 'card__metric' }, metric) : null, metric ? el('span', { class: 'card__metric' }, metric) : null,
), ),
); );

View File

@ -64,6 +64,23 @@ export function multiplier(attacking, defTypes, gen = 9) {
return m; 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. * 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

@ -13,4 +13,7 @@ export const ui = createStore('pdx.ui', {
sort: 'dex', sort: 'dex',
sortDesc: false, sortDesc: false,
filter: 'all', filter: 'all',
filterType: '',
filterGen: 0,
recent: [],
}); });

View File

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

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

@ -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();
@ -151,6 +161,52 @@ 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();
},
},
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. // 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 +231,29 @@ 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();
view.append( view.append(
el( el(
'header', 'header',
@ -199,7 +278,9 @@ export async function DexGrid() {
sortSelect, sortSelect,
dirBtn, dirBtn,
), ),
el('div', { class: 'feed-sort feed-filters' }, typeSelect, genSelect, randomBtn),
), ),
recentStrip,
grid, grid,
); );
@ -228,9 +309,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 +322,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 +337,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),
}); });

View File

@ -15,9 +15,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,6 +119,11 @@ export async function PokemonDetail(nationalId) {
return view; 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 types = typesForGeneration(pokemon, vgGen);
const mainType = types[0]; const mainType = types[0];
view.style.setProperty('--type-main', typeHex(mainType)); view.style.setProperty('--type-main', typeHex(mainType));
@ -107,6 +150,25 @@ 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(nationalId, {
@ -165,6 +227,18 @@ export async function PokemonDetail(nationalId) {
.filter((a) => !a.is_hidden || vgGen >= 5) .filter((a) => !a.is_hidden || vgGen >= 5)
.map((a) => prettify(a.ability.name) + (a.is_hidden ? ' (hidden)' : '')); .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( const aboutPanel = el(
'div', 'div',
{ class: 'ppanel' }, { class: 'ppanel' },
@ -183,6 +257,34 @@ export async function PokemonDetail(nationalId) {
) )
: null, : 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 --------------------------------------------- // ---- Stats panel ---------------------------------------------
@ -203,6 +305,7 @@ export async function PokemonDetail(nationalId) {
), ),
el('h3', { class: 'ppanel__sub' }, 'Type matchups (defending)'), el('h3', { class: 'ppanel__sub' }, 'Type matchups (defending)'),
TypeMatchups(types, vgGen), TypeMatchups(types, vgGen),
offensiveBlock(types, vgGen),
); );
// ---- Evolution panel -------------------------------------- // ---- Evolution panel --------------------------------------
@ -307,7 +410,7 @@ 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',
@ -372,3 +475,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,
),
);
}