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:
parent
8ebdb9d197
commit
2e4572428a
@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@ -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': [...] }
|
||||
|
||||
13
src/lib/type-resolve.js
Normal file
13
src/lib/type-resolve.js
Normal 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) || [];
|
||||
}
|
||||
@ -13,4 +13,7 @@ export const ui = createStore('pdx.ui', {
|
||||
sort: 'dex',
|
||||
sortDesc: false,
|
||||
filter: 'all',
|
||||
filterType: '',
|
||||
filterGen: 0,
|
||||
recent: [],
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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),
|
||||
});
|
||||
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user