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 | | 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`. | | **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 ## 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 / / "TMs only" / "HMs only" for the selected game and sort by power /
accuracy / recency — or, with a machine filter on, by TM/HM number accuracy / recency — or, with a machine filter on, by TM/HM number
(numbers are baked into the snapshot). Items filter by category. Each (numbers are baked into the snapshot). Items filter by category. Each
has its own detail page; the move, ability and item pages list the has its own detail page — the move page lists every Pokémon that can
Pokémon connected to them (learners / users / wild holders) as a learn it (filterable, narrowed to species that existed by the selected
filterable grid, narrowed to the selected game (by generation, or game's generation). Query, scroll, tab and filters persist.
exactly by version for held items). Query, scroll, tab and filters
persist.
- **Team** — a lineup of up to 6. Coverage leads with a "weak spots" - **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 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 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 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 stacks the six side by side. A third Calc mode is a damage calculator —
`#/compare` view with its own picker — up to 4 Pokémon, persisted).
A third Calc mode is a damage calculator —
pick (or quick-pick from your team) an attacker + move + defender, and 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 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, level/nature/EV controls; it's a base-stats estimate (31 IVs assumed,
no items/abilities/weather/terrain). All three follow the selected no items/abilities/weather/terrain). All three follow the selected
game — its generation's type chart, era-accurate typings (pre-Gen-6 game — its generation's type chart, era-accurate typings (pre-Gen-6
Clefairy is Normal, etc.), and era move data. Toggleable in the nav Clefairy is Normal, etc.), and era move data. Toggleable in the nav
from Settings. Links out to a Natures table and an interactive Type from Settings.
chart (tap a type for its offensive + defensive breakdown; both
gen-aware).
- **Settings** — theme (System / Light / Dark / Black / Sepia) + accent - **Settings** — theme (System / Light / Dark / Black / Sepia) + accent
colour, text size, an in-app reduce-motion override (on top of the OS colour, text size, an in-app reduce-motion override (on top of the OS
preference, which is always respected too), sprite style, haptic preference, which is always respected too), sprite style, haptic
feedback on catch (Vibration API), which screen to land on at launch, feedback on catch (Vibration API), which screen to land on at launch,
JSON export/import. JSON export/import.
- **PWA** — Workbox SW: precache shell + snapshot, SWR for API JSON, - **PWA** — Workbox SW: precache shell + snapshot, SWR for API JSON,
cache-first LRU for sprites, in-app update toast. Installable: PNG + cache-first LRU for sprites, in-app update toast.
maskable icons, `beforeinstallprompt` captured for an in-app "Install"
offer (toast + Settings button), manifest shortcuts to Team / Search /
Type chart.
Not yet done: version-exclusive badges, an interactive region map (PokéAPI Not yet done: version-exclusive badges, type/generation filters on the grid,
has no map imagery or coordinates), and a formal Lighthouse pass. 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 ## Notes

View File

@ -12,10 +12,7 @@
content="A responsive, offline-capable Pokédex reference powered by PokéAPI." content="A responsive, offline-capable Pokédex reference powered by PokéAPI."
/> />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/icon.svg" />
<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" />
<title>Pokédex</title> <title>Pokédex</title>
</head> </head>
<body> <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 { Nav } from './components/Nav.js';
import { settings, applyTheme } from './store/settings.js'; import { settings, applyTheme } from './store/settings.js';
import { ui } from './store/ui.js'; import { ui } from './store/ui.js';
import { onInstallChange, promptInstall } from './lib/install.js';
applyTheme(); 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 ------------------------------------------------- // --- Offline indicator -------------------------------------------------
const netPill = el('div', { class: 'netpill', role: 'status' }, 'Offline'); const netPill = el('div', { class: 'netpill', role: 'status' }, 'Offline');
function syncNet() { function syncNet() {

View File

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

View File

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

View File

@ -1591,9 +1591,6 @@
.settings__actions { .settings__actions {
flex-flow: row wrap; flex-flow: row wrap;
} }
.settings__install {
margin: 10px 0 0;
}
.field { .field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -2912,190 +2909,3 @@
font-weight: 700; font-weight: 700;
color: color-mix(in srgb, var(--type-main) 55%, var(--text-dim)); 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 { el, clear } from '../lib/dom.js';
import { loadSnapshot } from '../data/snapshot.js'; import { loadSnapshot } from '../data/snapshot.js';
import { settings } from '../store/settings.js'; import { settings } from '../store/settings.js';
import { prettify } from '../data/pokedex-resolver.js';
import { Sprite } from '../components/Sprite.js'; import { Sprite } from '../components/Sprite.js';
export async function AbilityDetail(id) { export async function AbilityDetail(id) {
@ -22,51 +21,9 @@ export async function AbilityDetail(id) {
} }
const style = settings.get().spriteStyle; const style = settings.get().spriteStyle;
const vg = settings.get().versionGroup; const mons = a.pokemon.map((pid) => snap.speciesById.get(pid)).filter(Boolean);
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);
mons.sort((x, y) => x.id - y.id); 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( clear(view).append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/search' }, ' Lookup')), el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/search' }, ' Lookup')),
el( el(
@ -88,21 +45,20 @@ export async function AbilityDetail(id) {
el( el(
'section', 'section',
{ class: 'detail__section' }, { class: 'detail__section' },
el( el('h2', {}, `Pokémon with this ability (${mons.length})`),
'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,
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.'), : 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 { el, clear } from '../lib/dom.js';
import { getItem } from '../data/api.js'; import { getItem } from '../data/api.js';
import { loadSnapshot } from '../data/snapshot.js';
import { prettify } from '../data/pokedex-resolver.js'; import { prettify } from '../data/pokedex-resolver.js';
import { settings } from '../store/settings.js';
import { Sprite } from '../components/Sprite.js';
const ITEM_SPRITE = (name) => const ITEM_SPRITE = (name) =>
`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/${name}.png`; `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/${name}.png`;
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
const english = (entries, key) => { const english = (entries, key) => {
const hit = (entries || []).find((e) => e.language.name === 'en'); 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' }); const view = el('section', { class: 'view lookup' });
view.append(el('div', { class: 'view--loading' }, 'Loading item…')); view.append(el('div', { class: 'view--loading' }, 'Loading item…'));
const snap = await loadSnapshot();
let d; let d;
try { try {
d = await getItem(id); 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) }); const img = el('img', { class: 'lookup__item-img', alt: d.name, src: ITEM_SPRITE(d.name) });
img.addEventListener('error', () => img.remove(), { once: true }); 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( clear(view).append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/search' }, ' Lookup')), el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/search' }, ' Lookup')),
el( el(
@ -119,22 +63,9 @@ export async function ItemDetail(id) {
d.attributes?.length d.attributes?.length
? fact('Attributes', d.attributes.map((a) => prettify(a.name)).join(', ')) ? fact('Attributes', d.attributes.map((a) => prettify(a.name)).join(', '))
: null, : 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)), 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; return view;
} }

View File

@ -356,20 +356,7 @@ export async function PokemonDetail(nationalId) {
const evYield = const evYield =
p.stats.filter((s) => s.effort > 0).map((s) => `${s.effort} ${STAT_LABEL[s.stat.name] || s.stat.name}`).join(', ') || 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 const heldItems = p.held_items?.map((h) => prettify(h.item.name)).join(', ') || '—';
? 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];
}),
)
: '—';
return el( return el(
'div', '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 { el, onTeardown } from '../lib/dom.js';
import { settings, applyTheme } from '../store/settings.js'; import { settings, applyTheme } from '../store/settings.js';
import { selection } from '../store/selection.js'; import { selection } from '../store/selection.js';
import { canInstall, onInstallChange, promptInstall } from '../lib/install.js';
export async function SettingsView() { export async function SettingsView() {
const view = el('section', { class: 'view settings' }); 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( view.append(
el('header', { class: 'view__header' }, el('h1', {}, 'Settings')), el('header', { class: 'view__header' }, el('h1', {}, 'Settings')),
installRow,
el( el(
'div', 'div',
{ class: 'settings__group' }, { class: 'settings__group' },
@ -182,12 +153,6 @@ export async function SettingsView() {
), ),
el('h2', {}, 'Your data'), 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, storageNote,
el('div', { class: 'settings__actions' }, el('div', { class: 'settings__actions' },
el('button', { el('button', {
@ -231,9 +196,7 @@ export async function SettingsView() {
), ),
); );
onTeardown(view, () => { onTeardown(view, () => {});
offInstall();
});
return view; return view;
} }

View File

@ -11,11 +11,18 @@ import { statAt, calcDamage } from '../lib/damage-calc.js';
import { NATURES } from '../data/natures.js'; import { NATURES } from '../data/natures.js';
import { Sprite } from '../components/Sprite.js'; import { Sprite } from '../components/Sprite.js';
import { TypeChip } from '../components/TypeChip.js'; import { TypeChip } from '../components/TypeChip.js';
import { CompareTable } from '../components/CompareTable.js';
import { openPokemonPicker } from '../components/PokemonPicker.js'; import { openPokemonPicker } from '../components/PokemonPicker.js';
const prettify = (s) => s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); const prettify = (s) => s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const loose = (s) => s.toLowerCase().replace(/[-\s]+/g, ' ').trim(); 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) => const multClass = (m) =>
m === 0 ? 'm0' : m === 0.25 ? 'm025' : m === 0.5 ? 'm05' : m === 2 ? 'm2' : m === 4 ? 'm4' : 'm1'; 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}×`); const multText = (m) => (m === 0.25 ? '¼' : m === 0.5 ? '½' : m === 1 ? '' : `${m}×`);
@ -65,11 +72,7 @@ export async function TeamView() {
'p', 'p',
{}, {},
`Up to ${MAX_TEAM} Pokémon — check weaknesses, coverage and stats. `, `Up to ${MAX_TEAM} Pokémon — check weaknesses, coverage and stats. `,
el('a', { class: 'link', href: '#/natures' }, 'Natures'), el('a', { class: 'link', href: '#/natures' }, 'Natures reference'),
' · ',
el('a', { class: 'link', href: '#/types' }, 'Type chart'),
' · ',
el('a', { class: 'link', href: '#/compare' }, 'Compare'),
), ),
), ),
seg, seg,
@ -306,31 +309,73 @@ export async function TeamView() {
'dl', 'dl',
{ class: 'pfacts' }, { class: 'pfacts' },
fact('Average BST', String(avgBst)), fact('Average BST', String(avgBst)),
fact('Fastest', monLink(fastest, `${fastest.stats?.spe ?? '?'} Spe`)), fact('Fastest', `${prettify(fastest.name)} (${fastest.stats?.spe ?? '?'} Spe)`),
fact( fact(
'Bulkiest', 'Bulkiest',
monLink( `${prettify(bulkiest.name)} (${
bulkiest, (bulkiest.stats?.hp || 0) + (bulkiest.stats?.def || 0) + (bulkiest.stats?.spd || 0)
`${(bulkiest.stats?.hp || 0) + (bulkiest.stats?.def || 0) + (bulkiest.stats?.spd || 0)} HP+Def+SpD`, } 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) { function compare(mem) {
const vg = snap.versionGroupByKey.get(settings.get().versionGroup); const vg = snap.versionGroupByKey.get(settings.get().versionGroup);
const gen = vg ? vg.generation : 9; 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 ------------------------------------------ // ---- 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. // The bundled PokéAPI snapshot can be a few hundred KB.
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
}, },
includeAssets: [ includeAssets: ['icon.svg', 'favicon.svg'],
'icon.svg',
'favicon.svg',
'apple-touch-icon.png',
],
manifest: { manifest: {
name: 'Pokédex', name: 'Pokédex',
short_name: 'Pokédex', short_name: 'Pokédex',
@ -39,34 +35,11 @@ export default defineConfig({
theme_color: '#b3161a', theme_color: '#b3161a',
categories: ['games', 'reference', 'utilities'], categories: ['games', 'reference', 'utilities'],
icons: [ 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', src: 'icon.svg',
sizes: '512x512', sizes: 'any',
type: 'image/png', type: 'image/svg+xml',
purpose: 'maskable', purpose: 'any 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' }],
}, },
], ],
}, },