Compare commits

..

No commits in common. "53f4f4381daeaed015a079221a32775a2796ec68" and "86e9bc60bc9ccd27da777bced03e3e387ed6e6e6" have entirely different histories.

21 changed files with 100 additions and 947 deletions

View File

@ -9,7 +9,7 @@ framework.
| Feature | How it works |
| --- | --- |
| **Game series selection** | Pick a *game* (PokéAPI version group, e.g. Scarlet & Violet). If it has more than one regional dex (Paldea / Kitakami / Blueberry) a sub-dex switcher appears. The choice drives which species show, their regional numbering, and which version's flavor text the detail page uses. Stored in `localStorage`. |
| **Unified Pokémon selection** | One tracking record per Pokémon, keyed by **National Dex id**. Mark Pikachu once and every game's view reflects it — each dex computes its own "Seen / Caught" totals by intersecting its species list with that single map. `#/progress` breaks that down for every game at once. The active Pokémon is also a single deep-linkable route (`#/pokemon/25`). |
| **Unified Pokémon selection** | One tracking record per Pokémon, keyed by **National Dex id**. Mark Pikachu once and every game's view reflects it — each dex computes its own "Seen / Caught" totals by intersecting its species list with that single map. The active Pokémon is also a single deep-linkable route (`#/pokemon/25`). |
## Data & storage split
@ -102,40 +102,33 @@ Working app with a type-themed UI:
/ "TMs only" / "HMs only" for the selected game and sort by power /
accuracy / recency — or, with a machine filter on, by TM/HM number
(numbers are baked into the snapshot). Items filter by category. Each
has its own detail page; the move, ability and item pages list the
Pokémon connected to them (learners / users / wild holders) as a
filterable grid, narrowed to the selected game (by generation, or
exactly by version for held items). Query, scroll, tab and filters
persist.
has its own detail page — the move page lists every Pokémon that can
learn it (filterable, narrowed to species that existed by the selected
game's generation). Query, scroll, tab and filters persist.
- **Team** — a lineup of up to 6. Coverage leads with a "weak spots"
summary (types that hit 2+ members, or that someone's weak to and nobody
resists), then a full matchup grid with a diverging resist◀│▶weak bar per
type; plus offensive STAB gaps and at-a-glance stats. A Compare table
stacks the six side by side (the same table is also a standalone
`#/compare` view with its own picker — up to 4 Pokémon, persisted).
A third Calc mode is a damage calculator —
stacks the six side by side. A third Calc mode is a damage calculator —
pick (or quick-pick from your team) an attacker + move + defender, and
get a damage range, % of HP, hits-to-KO, STAB/effectiveness badges, with
level/nature/EV controls; it's a base-stats estimate (31 IVs assumed,
no items/abilities/weather/terrain). All three follow the selected
game — its generation's type chart, era-accurate typings (pre-Gen-6
Clefairy is Normal, etc.), and era move data. Toggleable in the nav
from Settings. Links out to a Natures table and an interactive Type
chart (tap a type for its offensive + defensive breakdown; both
gen-aware).
from Settings.
- **Settings** — theme (System / Light / Dark / Black / Sepia) + accent
colour, text size, an in-app reduce-motion override (on top of the OS
preference, which is always respected too), sprite style, haptic
feedback on catch (Vibration API), which screen to land on at launch,
JSON export/import.
- **PWA** — Workbox SW: precache shell + snapshot, SWR for API JSON,
cache-first LRU for sprites, in-app update toast. Installable: PNG +
maskable icons, `beforeinstallprompt` captured for an in-app "Install"
offer (toast + Settings button), manifest shortcuts to Team / Search /
Type chart.
cache-first LRU for sprites, in-app update toast.
Not yet done: version-exclusive badges, an interactive region map (PokéAPI
has no map imagery or coordinates), and a formal Lighthouse pass.
Not yet done: version-exclusive badges, type/generation filters on the grid,
an interactive region map (PokéAPI has no map imagery or coordinates),
skeleton loaders, install-prompt handling, PNG/maskable raster icons
(currently SVG only), and a Lighthouse PWA pass.
## Notes

View File

@ -12,10 +12,7 @@
content="A responsive, offline-capable Pokédex reference powered by PokéAPI."
/>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Pokédex" />
<link rel="apple-touch-icon" href="/icon.svg" />
<title>Pokédex</title>
</head>
<body>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

View File

@ -1,74 +0,0 @@
import { el } from '../lib/dom.js';
import { Sprite } from './Sprite.js';
import { TypeChip } from './TypeChip.js';
import { typesForGen } from '../lib/type-resolve.js';
const STAT_ROWS = [
['hp', 'HP'],
['atk', 'Attack'],
['def', 'Defense'],
['spa', 'Sp. Atk'],
['spd', 'Sp. Def'],
['spe', 'Speed'],
];
/**
* Side-by-side stat / type comparison of 2+ snapshot species. Each stat
* row highlights the highest value; types are era-accurate for `gen`.
*/
export function CompareTable(members, { gen = 9, style } = {}) {
const mem = members;
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, { style, size: 44, alt: sp.name }),
el('span', {}, sp.name.replace(/-/g, ' ')),
),
),
'cmp__row--head',
),
row(
'Type',
mem.map((sp) => el('span', { class: 'cmp__cell' }, ...typesForGen(sp, gen).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',
),
);
}

View File

@ -1,48 +0,0 @@
/**
* Home-screen install: captures the browser's `beforeinstallprompt` so the
* app can offer installation on its own terms (a toast, a Settings button)
* instead of relying on the browser's mini-infobar.
*
* Import this early the event fires once, soon after load.
*/
let deferred = null;
const listeners = new Set();
const notify = () => {
for (const fn of listeners) fn(!!deferred);
};
if (typeof window !== 'undefined') {
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferred = e;
notify();
});
window.addEventListener('appinstalled', () => {
deferred = null;
notify();
});
}
export function canInstall() {
return !!deferred;
}
export function onInstallChange(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
/** Show the native install dialog. Resolves true if the user accepted. */
export async function promptInstall() {
if (!deferred) return false;
deferred.prompt();
let outcome = 'dismissed';
try {
({ outcome } = await deferred.userChoice);
} catch {
/* ignore */
}
deferred = null;
notify();
return outcome === 'accepted';
}

View File

@ -7,7 +7,6 @@ import { initRouter, rerender } from './router.js';
import { Nav } from './components/Nav.js';
import { settings, applyTheme } from './store/settings.js';
import { ui } from './store/ui.js';
import { onInstallChange, promptInstall } from './lib/install.js';
applyTheme();
@ -59,16 +58,6 @@ const updateSW = registerSW({
},
});
// --- Install prompt: offer it once, unobtrusively ---------------------
let installOffered = false;
onInstallChange((can) => {
if (!can || installOffered) return;
installOffered = true;
showToast('Install this Pokédex for offline, full-screen use?', 'Install', () => {
promptInstall();
});
});
// --- Offline indicator -------------------------------------------------
const netPill = el('div', { class: 'netpill', role: 'status' }, 'Offline');
function syncNet() {

View File

@ -8,9 +8,6 @@ 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 { TypeChartView } from './views/TypeChartView.js';
import { CompareView } from './views/CompareView.js';
import { ProgressView } from './views/ProgressView.js';
import { detailSkeleton, lookupSkeleton } from './components/skeletons.js';
import { prefersReducedMotion } from './store/settings.js';
@ -22,9 +19,6 @@ const routes = [
{ pattern: /^#\/ability\/(\d+)$/, view: (m) => AbilityDetail(Number(m[1])), skeleton: lookupSkeleton },
{ pattern: /^#\/team$/, view: () => TeamView() },
{ pattern: /^#\/natures$/, view: () => NaturesView() },
{ pattern: /^#\/types$/, view: () => TypeChartView() },
{ pattern: /^#\/compare$/, view: () => CompareView() },
{ pattern: /^#\/progress$/, view: () => ProgressView() },
{ pattern: /^#\/search$/, view: () => SearchView() },
{ pattern: /^#\/settings$/, view: () => SettingsView() },
];

View File

@ -20,7 +20,6 @@ export const ui = createStore('pdx.ui', {
filterMinBst: 0,
filterFullyEvolved: false,
recent: [],
compareIds: [],
toolsOpen: false,
teamMode: 'coverage',
mvType: '',

View File

@ -1591,9 +1591,6 @@
.settings__actions {
flex-flow: row wrap;
}
.settings__install {
margin: 10px 0 0;
}
.field {
display: flex;
flex-direction: column;
@ -2912,190 +2909,3 @@
font-weight: 700;
color: color-mix(in srgb, var(--type-main) 55%, var(--text-dim));
}
/* ---- Type chart page ---------------------------------------- */
.tchart__scroll {
overflow-x: auto;
scrollbar-width: thin;
-webkit-overflow-scrolling: touch;
}
.tchart {
display: grid;
grid-template-columns: 2.6rem repeat(var(--n), 1.9rem);
gap: 2px;
min-width: max-content;
padding-bottom: 4px;
}
.tchart__corner {
font-size: 0.5rem;
line-height: 1.1;
white-space: pre;
color: var(--text-dim);
display: grid;
place-items: center;
text-align: center;
}
.tchart__col,
.tchart__row {
font-family: inherit;
font-size: 0.56rem;
font-weight: 800;
letter-spacing: 0.02em;
border: none;
cursor: pointer;
padding: 0;
min-height: 1.9rem;
border-radius: 4px;
color: var(--text);
background: color-mix(in srgb, var(--tc) 26%, var(--surface-2));
}
.tchart__col.is-sel,
.tchart__row.is-sel {
background: var(--tc);
color: #fff;
}
.tchart__cell {
display: grid;
place-items: center;
min-height: 1.9rem;
font-size: 0.66rem;
font-weight: 700;
border-radius: 4px;
background: var(--surface-2);
font-variant-numeric: tabular-nums;
}
.tchart__cell.m2 {
background: color-mix(in srgb, var(--danger) 42%, transparent);
color: #fff;
}
.tchart__cell.m05 {
background: color-mix(in srgb, var(--good) 32%, transparent);
}
.tchart__cell.m0 {
background: color-mix(in srgb, var(--text-dim) 35%, var(--surface-2));
color: var(--text-dim);
}
.tchart__cell.is-hot {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.tchart__legend {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
font-size: 0.75rem;
color: var(--text-dim);
margin: 12px 0;
}
.tchart__legend .tchart__cell {
width: 1.5rem;
min-height: 1.5rem;
flex: none;
}
.tchart__summary {
margin-top: 16px;
}
.tchart__srow {
display: flex;
gap: 8px;
align-items: baseline;
flex-wrap: wrap;
margin-bottom: 8px;
}
.tchart__slabel {
font-size: 0.76rem;
font-weight: 700;
color: var(--text-dim);
flex: none;
min-width: 9rem;
}
.compareview__body {
margin-top: 16px;
}
/* ---- Progress by game -------------------------------------- */
.prog {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
}
.prog__gen {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-dim);
margin: 12px 0 2px;
}
.prog__row {
display: grid;
grid-template-columns: 8.5rem 1fr 3.6rem;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 10px;
background: var(--surface-2);
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.prog__row--nat {
background: color-mix(in srgb, var(--accent) 14%, var(--surface-2));
cursor: default;
font-weight: 700;
}
.prog__name {
font-size: 0.82rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.prog__track {
position: relative;
height: 8px;
border-radius: 999px;
background: var(--surface);
overflow: hidden;
}
.prog__seen,
.prog__caught {
position: absolute;
inset: 0 auto 0 0;
border-radius: 999px;
}
.prog__seen {
background: color-mix(in srgb, var(--accent) 35%, transparent);
}
.prog__caught {
background: var(--good);
}
.prog__count {
font-size: 0.74rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--text-dim);
text-align: right;
}
.prog__legend {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
color: var(--text-dim);
}
.prog__key {
display: inline-block;
width: 1.1rem;
height: 0.6rem;
border-radius: 999px;
vertical-align: middle;
}
.prog__key--caught { background: var(--good); }
.prog__key--seen { background: color-mix(in srgb, var(--accent) 35%, transparent); }

View File

@ -1,7 +1,6 @@
import { el, clear } from '../lib/dom.js';
import { loadSnapshot } from '../data/snapshot.js';
import { settings } from '../store/settings.js';
import { prettify } from '../data/pokedex-resolver.js';
import { Sprite } from '../components/Sprite.js';
export async function AbilityDetail(id) {
@ -22,51 +21,9 @@ export async function AbilityDetail(id) {
}
const style = settings.get().spriteStyle;
const vg = settings.get().versionGroup;
const gen = snap.versionGroupByKey.get(vg)?.generation ?? 9;
// All-games: every species with the ability. A specific game: only those
// that existed by its generation.
let mons = a.pokemon.map((pid) => snap.speciesById.get(pid)).filter(Boolean);
if (vg !== 'all') mons = mons.filter((sp) => sp.generation <= gen);
const mons = a.pokemon.map((pid) => snap.speciesById.get(pid)).filter(Boolean);
mons.sort((x, y) => x.id - y.id);
const CAP = 400;
const grid = el('div', { class: 'ab-mons' });
function paint(q) {
const qq = (q || '').trim().toLowerCase().replace(/\s+/g, '-');
const shown = qq ? mons.filter((sp) => sp.name.includes(qq)) : mons;
clear(grid);
if (!shown.length) {
grid.append(el('p', { class: 'detail__muted' }, 'No matches.'));
return;
}
for (const sp of shown.slice(0, CAP)) {
grid.append(
el(
'a',
{ class: 'ab-mon', href: `#/pokemon/${sp.id}` },
Sprite(sp.id, { style, alt: sp.name, size: 48 }),
el('span', {}, sp.name.replace(/-/g, ' ')),
),
);
}
if (shown.length > CAP) {
grid.append(
el('p', { class: 'detail__muted' }, `+${shown.length - CAP} more — filter to narrow.`),
);
}
}
paint('');
const filterInput = el('input', {
type: 'search',
class: 'search__input learners__filter',
placeholder: 'Filter Pokémon…',
oninput: (e) => paint(e.target.value),
});
const predatesGame = vg !== 'all' && a.generation > gen;
clear(view).append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/search' }, ' Lookup')),
el(
@ -88,21 +45,20 @@ export async function AbilityDetail(id) {
el(
'section',
{ class: 'detail__section' },
el(
'h2',
{},
`Pokémon with this ability${vg === 'all' ? '' : ` in ${prettify(vg)}`} (${mons.length})`,
),
predatesGame
? el(
'p',
{ class: 'detail__muted' },
`Not in ${prettify(vg)} — this ability was introduced in Gen ${a.generation}.`,
)
: null,
mons.length > 24 ? filterInput : null,
el('h2', {}, `Pokémon with this ability (${mons.length})`),
mons.length
? grid
? 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.'),
),
);

View File

@ -1,122 +0,0 @@
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 { Sprite } from '../components/Sprite.js';
import { CompareTable } from '../components/CompareTable.js';
import { openPokemonPicker } from '../components/PokemonPicker.js';
const MAX = 4;
export async function CompareView() {
const view = el('section', { class: 'view lookup compareview' });
const snap = await loadSnapshot();
const vg = snap.versionGroupByKey.get(settings.get().versionGroup);
const gen = vg ? vg.generation : 9;
let ids = (ui.get().compareIds || []).filter((id) => snap.speciesById.get(id)).slice(0, MAX);
const persist = () => ui.set({ compareIds: ids });
const slots = el('div', { class: 'lineup' });
const body = el('div', { class: 'compareview__body' });
function render() {
const style = settings.get().spriteStyle;
const mem = ids.map((id) => snap.speciesById.get(id)).filter(Boolean);
clear(slots);
for (const sp of mem) {
slots.append(
el(
'div',
{ class: 'lineup__slot' },
el(
'button',
{
class: 'lineup__x',
type: 'button',
'aria-label': `Remove ${sp.name}`,
onclick: () => {
ids = ids.filter((x) => x !== sp.id);
persist();
render();
},
},
'✕',
),
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) {
slots.append(
el(
'button',
{
class: 'lineup__add',
type: 'button',
onclick: () =>
openPokemonPicker((id) => {
if (ids.includes(id) || ids.length >= MAX) return;
ids = [...ids, id];
persist();
render();
}),
},
'',
el('span', {}, 'Add'),
),
);
}
if (mem.length) {
slots.append(
el(
'button',
{
class: 'lineup__clear',
type: 'button',
onclick: () => {
ids = [];
persist();
render();
},
},
'Clear',
),
);
}
clear(body);
body.append(
mem.length >= 2
? CompareTable(mem, { gen, style })
: el('p', { class: 'detail__muted' }, 'Add two or more Pokémon to compare their types and base stats.'),
);
}
clear(view).append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/team' }, ' Team')),
el(
'header',
{ class: 'view__header' },
el('h1', {}, 'Compare'),
el(
'p',
{},
vg ? `Types shown for ${vg.name}.` : 'Types shown for the newest games.',
),
),
slots,
body,
);
render();
const offSettings = settings.subscribe(render);
onTeardown(view, () => offSettings());
return view;
}

View File

@ -1,13 +1,9 @@
import { el, clear } from '../lib/dom.js';
import { getItem } from '../data/api.js';
import { loadSnapshot } from '../data/snapshot.js';
import { prettify } from '../data/pokedex-resolver.js';
import { settings } from '../store/settings.js';
import { Sprite } from '../components/Sprite.js';
const ITEM_SPRITE = (name) =>
`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/${name}.png`;
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
const english = (entries, key) => {
const hit = (entries || []).find((e) => e.language.name === 'en');
@ -22,8 +18,6 @@ export async function ItemDetail(id) {
const view = el('section', { class: 'view lookup' });
view.append(el('div', { class: 'view--loading' }, 'Loading item…'));
const snap = await loadSnapshot();
let d;
try {
d = await getItem(id);
@ -48,56 +42,6 @@ export async function ItemDetail(id) {
const img = el('img', { class: 'lookup__item-img', alt: d.name, src: ITEM_SPRITE(d.name) });
img.addEventListener('error', () => img.remove(), { once: true });
// Pokémon that hold this item in the wild. held_by_pokemon carries a
// per-version breakdown, so for a specific game we can filter exactly.
const vg = settings.get().versionGroup;
const versions = new Set(snap.versionGroupByKey.get(vg)?.versions || []);
const holders = (d.held_by_pokemon || [])
.filter(
(h) =>
vg === 'all' ||
!versions.size ||
(h.version_details || []).some((vd) => versions.has(vd.version.name)),
)
.map((h) => snap.speciesById.get(idFromUrl(h.pokemon.url)))
.filter(Boolean)
.sort((a, b) => a.id - b.id);
const style = settings.get().spriteStyle;
const HOLDER_CAP = 400;
const holdersGrid = el('div', { class: 'ab-mons' });
function paintHolders(q) {
const qq = (q || '').trim().toLowerCase().replace(/\s+/g, '-');
const shown = qq ? holders.filter((sp) => sp.name.includes(qq)) : holders;
clear(holdersGrid);
if (!shown.length) {
holdersGrid.append(el('p', { class: 'detail__muted' }, 'No matches.'));
return;
}
for (const sp of shown.slice(0, HOLDER_CAP)) {
holdersGrid.append(
el(
'a',
{ class: 'ab-mon', href: `#/pokemon/${sp.id}` },
Sprite(sp.id, { style, alt: sp.name, size: 48 }),
el('span', {}, sp.name.replace(/-/g, ' ')),
),
);
}
if (shown.length > HOLDER_CAP) {
holdersGrid.append(
el('p', { class: 'detail__muted' }, `+${shown.length - HOLDER_CAP} more — filter to narrow.`),
);
}
}
paintHolders('');
const holderFilter = el('input', {
type: 'search',
class: 'search__input learners__filter',
placeholder: 'Filter Pokémon…',
oninput: (e) => paintHolders(e.target.value),
});
clear(view).append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/search' }, ' Lookup')),
el(
@ -119,22 +63,9 @@ export async function ItemDetail(id) {
d.attributes?.length
? fact('Attributes', d.attributes.map((a) => prettify(a.name)).join(', '))
: null,
fact('Held by', `${holders.length} Pokémon`),
fact('Held by', `${d.held_by_pokemon.length} Pokémon`),
),
el('section', { class: 'detail__section' }, el('h2', {}, 'Effect'), el('p', {}, effect)),
holders.length
? el(
'section',
{ class: 'detail__section' },
el(
'h2',
{},
`Held in the wild by${vg === 'all' ? '' : ` in ${prettify(vg)}`} (${holders.length})`,
),
holders.length > 24 ? holderFilter : null,
holdersGrid,
)
: null,
);
return view;
}

View File

@ -356,20 +356,7 @@ export async function PokemonDetail(nationalId) {
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?.length
? el(
'span',
{},
...p.held_items.flatMap((h, i) => {
const link = el(
'a',
{ class: 'link', href: `#/item/${idFromUrl(h.item.url)}` },
prettify(h.item.name),
);
return i === 0 ? [link] : [', ', link];
}),
)
: '—';
const heldItems = p.held_items?.map((h) => prettify(h.item.name)).join(', ') || '—';
return el(
'div',
{},

View File

@ -1,94 +0,0 @@
import { el, clear, onTeardown } from '../lib/dom.js';
import { loadSnapshot } from '../data/snapshot.js';
import { settings } from '../store/settings.js';
import { selection, stats } from '../store/selection.js';
import { dexesForVersionGroup, versionGroupsByGeneration } from '../data/pokedex-resolver.js';
function bar(seen, caught, total) {
const pc = total ? (caught / total) * 100 : 0;
const ps = total ? (seen / total) * 100 : 0;
const track = el('div', { class: 'prog__track' });
const seenEl = el('div', { class: 'prog__seen' });
const caughtEl = el('div', { class: 'prog__caught' });
seenEl.style.width = `${ps}%`;
caughtEl.style.width = `${pc}%`;
track.append(seenEl, caughtEl);
return track;
}
export async function ProgressView() {
const view = el('section', { class: 'view lookup progressview' });
const snap = await loadSnapshot();
const speciesIdsFor = (vgKey) => {
const ids = new Set();
for (const dex of dexesForVersionGroup(snap, vgKey)) {
for (const [sid] of dex.entries) ids.add(sid);
}
return [...ids];
};
const list = el('div', { class: 'prog' });
function render() {
clear(list);
// National total
const nat = stats(snap.species.map((s) => s.id));
list.append(
el(
'div',
{ class: 'prog__row prog__row--nat' },
el('span', { class: 'prog__name' }, 'National Dex'),
bar(nat.seen, nat.caught, nat.total),
el('span', { class: 'prog__count' }, `${nat.caught} / ${nat.total}`),
),
);
for (const { generation, versionGroups } of versionGroupsByGeneration(snap)) {
list.append(el('h3', { class: 'prog__gen' }, `Gen ${generation.id} · ${generation.name}`));
for (const vg of versionGroups) {
const s = stats(speciesIdsFor(vg.key));
list.append(
el(
'button',
{
type: 'button',
class: 'prog__row',
onclick: () => {
settings.set({ versionGroup: vg.key, pokedex: null });
location.hash = '#/';
},
},
el('span', { class: 'prog__name' }, vg.name),
bar(s.seen, s.caught, s.total),
el('span', { class: 'prog__count' }, `${s.caught} / ${s.total}`),
),
);
}
}
}
render();
clear(view).append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/settings' }, ' Settings')),
el(
'header',
{ class: 'view__header' },
el('h1', {}, 'Progress by game'),
el('p', {}, 'Your one unified caught record, counted against each games dex. Tap a game to switch to it.'),
),
el(
'p',
{ class: 'prog__legend' },
el('span', { class: 'prog__key prog__key--caught' }),
' caught ',
el('span', { class: 'prog__key prog__key--seen' }),
' seen',
),
list,
);
const off = selection.subscribe(render);
onTeardown(view, () => off());
return view;
}

View File

@ -1,7 +1,6 @@
import { el, onTeardown } from '../lib/dom.js';
import { settings, applyTheme } from '../store/settings.js';
import { selection } from '../store/selection.js';
import { canInstall, onInstallChange, promptInstall } from '../lib/install.js';
export async function SettingsView() {
const view = el('section', { class: 'view settings' });
@ -137,36 +136,8 @@ export async function SettingsView() {
},
});
// Install (only when the browser has offered it and the app isn't
// already installed).
const installRow = el('div', { class: 'settings__install' });
function syncInstall() {
installRow.replaceChildren(
canInstall()
? el(
'button',
{
class: 'button',
type: 'button',
onclick: () => promptInstall(),
},
'Install app',
)
: el(
'p',
{ class: 'settings__note' },
window.matchMedia('(display-mode: standalone)').matches
? 'Running as an installed app.'
: 'Use your browser menu to add this Pokédex to your home screen.',
),
);
}
syncInstall();
const offInstall = onInstallChange(syncInstall);
view.append(
el('header', { class: 'view__header' }, el('h1', {}, 'Settings')),
installRow,
el(
'div',
{ class: 'settings__group' },
@ -182,12 +153,6 @@ export async function SettingsView() {
),
el('h2', {}, 'Your data'),
el(
'p',
{ class: 'settings__note' },
el('a', { class: 'link', href: '#/progress' }, 'Progress by game'),
' — caught totals against every games dex.',
),
storageNote,
el('div', { class: 'settings__actions' },
el('button', {
@ -231,9 +196,7 @@ export async function SettingsView() {
),
);
onTeardown(view, () => {
offInstall();
});
onTeardown(view, () => {});
return view;
}

View File

@ -11,11 +11,18 @@ import { statAt, calcDamage } from '../lib/damage-calc.js';
import { NATURES } from '../data/natures.js';
import { Sprite } from '../components/Sprite.js';
import { TypeChip } from '../components/TypeChip.js';
import { CompareTable } from '../components/CompareTable.js';
import { openPokemonPicker } from '../components/PokemonPicker.js';
const prettify = (s) => s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const loose = (s) => s.toLowerCase().replace(/[-\s]+/g, ' ').trim();
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}×`);
@ -65,11 +72,7 @@ export async function TeamView() {
'p',
{},
`Up to ${MAX_TEAM} Pokémon — check weaknesses, coverage and stats. `,
el('a', { class: 'link', href: '#/natures' }, 'Natures'),
' · ',
el('a', { class: 'link', href: '#/types' }, 'Type chart'),
' · ',
el('a', { class: 'link', href: '#/compare' }, 'Compare'),
el('a', { class: 'link', href: '#/natures' }, 'Natures reference'),
),
),
seg,
@ -306,31 +309,73 @@ export async function TeamView() {
'dl',
{ class: 'pfacts' },
fact('Average BST', String(avgBst)),
fact('Fastest', monLink(fastest, `${fastest.stats?.spe ?? '?'} Spe`)),
fact('Fastest', `${prettify(fastest.name)} (${fastest.stats?.spe ?? '?'} Spe)`),
fact(
'Bulkiest',
monLink(
bulkiest,
`${(bulkiest.stats?.hp || 0) + (bulkiest.stats?.def || 0) + (bulkiest.stats?.spd || 0)} HP+Def+SpD`,
),
`${prettify(bulkiest.name)} (${
(bulkiest.stats?.hp || 0) + (bulkiest.stats?.def || 0) + (bulkiest.stats?.spd || 0)
} HP+Def+SpD)`,
),
),
);
}
function monLink(sp, tail) {
return el(
'span',
{},
el('a', { class: 'link', href: `#/pokemon/${sp.id}` }, prettify(sp.name)),
` (${tail})`,
);
}
function compare(mem) {
const vg = snap.versionGroupByKey.get(settings.get().versionGroup);
const gen = vg ? vg.generation : 9;
return CompareTable(mem, { gen, style: settings.get().spriteStyle });
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' }, ...typesForGen(sp, gen).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',
),
);
}
// ---- Damage calculator ------------------------------------------

View File

@ -1,146 +0,0 @@
import { el, clear } from '../lib/dom.js';
import { settings } from '../store/settings.js';
import { loadSnapshot } from '../data/snapshot.js';
import { prettify } from '../data/pokedex-resolver.js';
import { TYPES, multiplier, offensiveSummary, defensiveMatchups } from '../data/type-chart.js';
import { TypeChip } from '../components/TypeChip.js';
import { typeHex } from '../lib/type-color.js';
const ABBR = {
normal: 'NOR', fire: 'FIR', water: 'WAT', electric: 'ELE', grass: 'GRA', ice: 'ICE',
fighting: 'FIG', poison: 'POI', ground: 'GRD', flying: 'FLY', psychic: 'PSY', bug: 'BUG',
rock: 'ROC', ghost: 'GHO', dragon: 'DRA', dark: 'DRK', steel: 'STE', fairy: 'FAI',
};
const cellText = (m) => (m === 0 ? '0' : m === 0.5 ? '½' : m === 2 ? '2' : '');
const cellClass = (m) => (m === 0 ? 'm0' : m === 0.5 ? 'm05' : m === 2 ? 'm2' : 'm1');
export async function TypeChartView() {
const view = el('section', { class: 'view lookup tchartview' });
const snap = await loadSnapshot();
const st = settings.get();
const vg = snap.versionGroupByKey.get(st.versionGroup);
const gen = vg ? vg.generation : 9;
// Fairy is Gen 6+, Steel/Dark are Gen 2+.
const types = TYPES.filter((t) => {
if (t === 'fairy' && gen < 6) return false;
if ((t === 'steel' || t === 'dark') && gen < 2) return false;
return true;
});
let selected = null; // a type name, or null
const grid = el('div', { class: 'tchart' });
const summary = el('div', { class: 'tchart__summary' });
function select(t) {
selected = selected === t ? null : t;
paint();
}
function paint() {
grid.style.setProperty('--n', String(types.length));
clear(grid);
// corner
grid.append(el('span', { class: 'tchart__corner' }, 'ATK→\nDEF↓'));
// column headers (defending)
for (const def of types) {
grid.append(
el(
'button',
{
type: 'button',
class: `tchart__col${selected === def ? ' is-sel' : ''}`,
style: `--tc:${typeHex(def)}`,
title: prettify(def),
onclick: () => select(def),
},
ABBR[def],
),
);
}
// rows (attacking)
for (const atk of types) {
grid.append(
el(
'button',
{
type: 'button',
class: `tchart__row${selected === atk ? ' is-sel' : ''}`,
style: `--tc:${typeHex(atk)}`,
title: prettify(atk),
onclick: () => select(atk),
},
ABBR[atk],
),
);
for (const def of types) {
const m = multiplier(atk, [def], gen);
const hot = selected === atk || selected === def;
grid.append(
el(
'span',
{ class: `tchart__cell ${cellClass(m)}${hot ? ' is-hot' : ''}` },
cellText(m),
),
);
}
}
clear(summary);
if (!selected) {
summary.append(
el('p', { class: 'detail__muted' }, 'Tap a type on the edge to see everything it hits and everything that hits it.'),
);
return;
}
const inEra = (list) => (list || []).filter((t) => types.includes(t));
const off = offensiveSummary([selected], gen);
const def = defensiveMatchups([selected], gen);
const chipRow = (label, list) =>
list.length
? el('div', { class: 'tchart__srow' }, el('span', { class: 'tchart__slabel' }, label), el('span', { class: 'matchups__types' }, ...list.map(TypeChip)))
: null;
summary.append(
el('h3', { class: 'ppanel__sub' }, `${prettify(selected)} — attacking`),
chipRow('Super effective vs', inEra(off.strong)),
chipRow('Not very effective / no effect vs', inEra(off.walls)),
el('h3', { class: 'ppanel__sub' }, `${prettify(selected)} — defending`),
chipRow('Weak to', inEra(def['2'])),
chipRow('Resists', inEra(def['0.5'])),
chipRow('Immune to', inEra(def['0'])),
);
}
paint();
clear(view).append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/team' }, ' Team')),
el(
'header',
{ class: 'view__header' },
el('h1', {}, 'Type chart'),
el(
'p',
{},
st.versionGroup === 'all'
? 'Modern rules (Gen 6+).'
: `${prettify(st.versionGroup)} — Gen ${gen} rules${
gen < 6 ? ', no Fairy' : ''
}${gen < 2 ? ', no Steel/Dark' : ''}.`,
),
),
el(
'div',
{ class: 'tchart__legend' },
el('span', { class: 'tchart__cell m2' }, '2'),
' super effective ',
el('span', { class: 'tchart__cell m05' }, '½'),
' resisted ',
el('span', { class: 'tchart__cell m0' }, '0'),
' no effect',
),
el('div', { class: 'tchart__scroll' }, grid),
summary,
);
return view;
}

View File

@ -21,11 +21,7 @@ export default defineConfig({
// The bundled PokéAPI snapshot can be a few hundred KB.
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
},
includeAssets: [
'icon.svg',
'favicon.svg',
'apple-touch-icon.png',
],
includeAssets: ['icon.svg', 'favicon.svg'],
manifest: {
name: 'Pokédex',
short_name: 'Pokédex',
@ -39,34 +35,11 @@ export default defineConfig({
theme_color: '#b3161a',
categories: ['games', 'reference', 'utilities'],
icons: [
{ src: 'icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
{ src: 'pwa-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{
src: 'pwa-maskable-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
shortcuts: [
{
name: 'Team builder',
short_name: 'Team',
url: './#/team',
icons: [{ src: 'pwa-192.png', sizes: '192x192' }],
},
{
name: 'Search',
short_name: 'Search',
url: './#/search',
icons: [{ src: 'pwa-192.png', sizes: '192x192' }],
},
{
name: 'Type chart',
short_name: 'Types',
url: './#/types',
icons: [{ src: 'pwa-192.png', sizes: '192x192' }],
src: 'icon.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any maskable',
},
],
},