Compare commits
No commits in common. "53f4f4381daeaed015a079221a32775a2796ec68" and "86e9bc60bc9ccd27da777bced03e3e387ed6e6e6" have entirely different histories.
53f4f4381d
...
86e9bc60bc
29
README.md
29
README.md
@ -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
|
||||
|
||||
|
||||
@ -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 |
@ -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',
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -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';
|
||||
}
|
||||
11
src/main.js
11
src/main.js
@ -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() {
|
||||
|
||||
@ -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() },
|
||||
];
|
||||
|
||||
@ -20,7 +20,6 @@ export const ui = createStore('pdx.ui', {
|
||||
filterMinBst: 0,
|
||||
filterFullyEvolved: false,
|
||||
recent: [],
|
||||
compareIds: [],
|
||||
toolsOpen: false,
|
||||
teamMode: 'coverage',
|
||||
mvType: '',
|
||||
|
||||
@ -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); }
|
||||
|
||||
@ -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.'),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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',
|
||||
{},
|
||||
|
||||
@ -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 game’s 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;
|
||||
}
|
||||
@ -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 game’s dex.',
|
||||
),
|
||||
storageNote,
|
||||
el('div', { class: 'settings__actions' },
|
||||
el('button', {
|
||||
@ -231,9 +196,7 @@ export async function SettingsView() {
|
||||
),
|
||||
);
|
||||
|
||||
onTeardown(view, () => {
|
||||
offInstall();
|
||||
});
|
||||
onTeardown(view, () => {});
|
||||
return view;
|
||||
}
|
||||
|
||||
|
||||
@ -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 ------------------------------------------
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user