Team Builder + Compare views
- New #/team view: a persisted lineup of up to 6 Pokémon (pdx.team store)
with a search-picker overlay, plus a Coverage/Compare toggle
- Coverage: 18x6 weakness table (colour-coded multipliers + per-type weak
count), offensive STAB gaps, team at-a-glance (avg BST, fastest, bulkiest)
- Compare: per-member column table of types + 6 stats + BST with mini bars
and row-max highlighting; scrolls horizontally past 4 members
- '+ Team' / '✓ In team' toggle on the Pokémon detail page (disabled when full)
- Nav gains a Team item; a Settings toggle ('Show Team in the navigation
bar', on by default) hides it from the bar without removing the route
- type-chart.js: defenseVector() helper
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
5717b0a38a
commit
ddc84c2a48
@ -1,8 +1,10 @@
|
||||
import { el } from '../lib/dom.js';
|
||||
import { openGameSheet } from './GameSheet.js';
|
||||
import { settings } from '../store/settings.js';
|
||||
|
||||
const ITEMS = [
|
||||
{ label: 'Dex', icon: '▦', href: '#/', match: (h) => h === '#/' || h === '' || h === '#' },
|
||||
{ label: 'Team', icon: '⛨', href: '#/team', match: (h) => h.startsWith('#/team'), optional: 'showTeamNav' },
|
||||
{ label: 'Games', icon: '◉', action: openGameSheet },
|
||||
{ label: 'Search', icon: '⌕', href: '#/search', match: (h) => h.startsWith('#/search') },
|
||||
{ label: 'Settings', icon: '⚙', href: '#/settings', match: (h) => h.startsWith('#/settings') },
|
||||
@ -10,41 +12,47 @@ const ITEMS = [
|
||||
|
||||
/**
|
||||
* One nav element, styled by CSS into a bottom bar on phones and a sidebar
|
||||
* on wide screens. Links derive their active state from the hash; the Games
|
||||
* item summons an overlay instead of navigating.
|
||||
* on wide screens. The Games item summons an overlay; optional items (Team)
|
||||
* can be hidden from Settings.
|
||||
*/
|
||||
export function Nav() {
|
||||
const links = ITEMS.map((item) => {
|
||||
const inner = [
|
||||
el('span', { class: 'nav__icon', 'aria-hidden': 'true' }, item.icon),
|
||||
el('span', { class: 'nav__label' }, item.label),
|
||||
];
|
||||
return item.action
|
||||
? el(
|
||||
'button',
|
||||
{ class: 'nav__link', type: 'button', onclick: () => item.action() },
|
||||
...inner,
|
||||
)
|
||||
: el('a', { class: 'nav__link', href: item.href }, ...inner);
|
||||
});
|
||||
const nav = el('nav', { class: 'nav', 'aria-label': 'Primary' });
|
||||
let links = [];
|
||||
|
||||
const nav = el(
|
||||
'nav',
|
||||
{ class: 'nav', 'aria-label': 'Primary' },
|
||||
el('span', { class: 'nav__brand' }, 'Pokédex'),
|
||||
...links,
|
||||
);
|
||||
function build() {
|
||||
const st = settings.get();
|
||||
const items = ITEMS.filter((it) => !it.optional || st[it.optional]);
|
||||
links = items.map((item) => {
|
||||
const inner = [
|
||||
el('span', { class: 'nav__icon', 'aria-hidden': 'true' }, item.icon),
|
||||
el('span', { class: 'nav__label' }, item.label),
|
||||
];
|
||||
return item.action
|
||||
? el('button', { class: 'nav__link', type: 'button', onclick: () => item.action() }, ...inner)
|
||||
: el('a', { class: 'nav__link', href: item.href }, ...inner);
|
||||
});
|
||||
nav.replaceChildren(el('span', { class: 'nav__brand' }, 'Pokédex'), ...links);
|
||||
nav._items = items;
|
||||
sync();
|
||||
}
|
||||
|
||||
function sync() {
|
||||
const hash = location.hash || '#/';
|
||||
ITEMS.forEach((item, i) => {
|
||||
(nav._items || []).forEach((item, i) => {
|
||||
if (!item.match) return;
|
||||
links[i].classList.toggle('is-active', item.match(hash));
|
||||
links[i].setAttribute('aria-current', item.match(hash) ? 'page' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
build();
|
||||
let lastShowTeam = settings.get().showTeamNav;
|
||||
settings.subscribe((s) => {
|
||||
if (s.showTeamNav !== lastShowTeam) {
|
||||
lastShowTeam = s.showTeamNav;
|
||||
build();
|
||||
}
|
||||
});
|
||||
window.addEventListener('hashchange', sync);
|
||||
sync();
|
||||
return nav;
|
||||
}
|
||||
|
||||
98
src/components/PokemonPicker.js
Normal file
98
src/components/PokemonPicker.js
Normal file
@ -0,0 +1,98 @@
|
||||
import { el, clear } from '../lib/dom.js';
|
||||
import { loadSnapshot } from '../data/snapshot.js';
|
||||
import { settings } from '../store/settings.js';
|
||||
import { Sprite } from './Sprite.js';
|
||||
import { TypeChip } from './TypeChip.js';
|
||||
|
||||
let openInstance = null;
|
||||
|
||||
/**
|
||||
* A summonable overlay to pick a Pokémon from the full snapshot list.
|
||||
* `onPick(id)` fires per selection; the sheet stays open so you can add
|
||||
* several. Dismiss with the backdrop, ✕, or Escape.
|
||||
*/
|
||||
export async function openPokemonPicker(onPick) {
|
||||
if (openInstance) return;
|
||||
const snap = await loadSnapshot();
|
||||
|
||||
const backdrop = el('div', {
|
||||
class: 'sheet-backdrop',
|
||||
onclick: (e) => {
|
||||
if (e.target === backdrop) close();
|
||||
},
|
||||
});
|
||||
const panel = el('div', {
|
||||
class: 'gsheet',
|
||||
role: 'dialog',
|
||||
'aria-modal': 'true',
|
||||
'aria-label': 'Add a Pokémon',
|
||||
});
|
||||
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') close();
|
||||
};
|
||||
function close() {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
backdrop.classList.remove('is-open');
|
||||
setTimeout(() => {
|
||||
backdrop.remove();
|
||||
openInstance = null;
|
||||
}, 220);
|
||||
}
|
||||
|
||||
const results = el('div', { class: 'picker__list' });
|
||||
const input = el('input', {
|
||||
class: 'search__input',
|
||||
type: 'search',
|
||||
placeholder: 'Name, number or type…',
|
||||
autofocus: true,
|
||||
oninput: (e) => run(e.target.value),
|
||||
});
|
||||
|
||||
function run(raw) {
|
||||
const q = raw.trim().toLowerCase();
|
||||
clear(results);
|
||||
if (!q) return;
|
||||
const style = settings.get().spriteStyle;
|
||||
const matches = snap.species
|
||||
.filter(
|
||||
(s) =>
|
||||
s.name.replace(/-/g, ' ').includes(q) ||
|
||||
String(s.id) === q ||
|
||||
(s.types || []).some((t) => t === q),
|
||||
)
|
||||
.slice(0, 50);
|
||||
for (const s of matches) {
|
||||
results.append(
|
||||
el(
|
||||
'button',
|
||||
{
|
||||
class: 'picker__row',
|
||||
type: 'button',
|
||||
onclick: () => onPick(s.id),
|
||||
},
|
||||
Sprite(s.id, { style, alt: s.name, size: 44 }),
|
||||
el('span', { class: 'search__num' }, `#${String(s.id).padStart(4, '0')}`),
|
||||
el('span', { class: 'search__name' }, s.name.replace(/-/g, ' ')),
|
||||
el('span', { class: 'search__types' }, ...(s.types || []).map(TypeChip)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
panel.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'gsheet__head' },
|
||||
el('h2', {}, 'Add a Pokémon'),
|
||||
el('button', { class: 'gsheet__close', type: 'button', 'aria-label': 'Close', onclick: close }, '✕'),
|
||||
),
|
||||
input,
|
||||
results,
|
||||
);
|
||||
backdrop.append(panel);
|
||||
document.body.append(backdrop);
|
||||
document.addEventListener('keydown', onKey);
|
||||
openInstance = backdrop;
|
||||
requestAnimationFrame(() => backdrop.classList.add('is-open'));
|
||||
}
|
||||
@ -64,6 +64,14 @@ export function multiplier(attacking, defTypes, gen = 9) {
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Per-attacking-type multiplier map against a defender, e.g. { fire: 2, water: 0.5, ... }. */
|
||||
export function defenseVector(defTypes, gen = 9) {
|
||||
const out = {};
|
||||
const atk = gen < 6 ? TYPES.filter((t) => t !== 'fairy') : TYPES;
|
||||
for (const a of atk) out[a] = multiplier(a, defTypes, gen);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a Pokémon's STAB types hit, from the attacker's side.
|
||||
* Returns { strong: [types any STAB is 2x+ against], walls: [types that
|
||||
|
||||
@ -5,6 +5,7 @@ import { SearchView } from './views/SearchView.js';
|
||||
import { SettingsView } from './views/SettingsView.js';
|
||||
import { MoveDetail } from './views/MoveDetail.js';
|
||||
import { ItemDetail } from './views/ItemDetail.js';
|
||||
import { TeamView } from './views/TeamView.js';
|
||||
import { detailSkeleton, lookupSkeleton } from './components/skeletons.js';
|
||||
|
||||
const routes = [
|
||||
@ -12,6 +13,7 @@ const routes = [
|
||||
{ pattern: /^#\/pokemon\/(\d+)$/, view: (m) => PokemonDetail(Number(m[1])), skeleton: detailSkeleton },
|
||||
{ pattern: /^#\/move\/(\d+)$/, view: (m) => MoveDetail(Number(m[1])), skeleton: lookupSkeleton },
|
||||
{ pattern: /^#\/item\/(\d+)$/, view: (m) => ItemDetail(Number(m[1])), skeleton: lookupSkeleton },
|
||||
{ pattern: /^#\/team$/, view: () => TeamView() },
|
||||
{ pattern: /^#\/search$/, view: () => SearchView() },
|
||||
{ pattern: /^#\/settings$/, view: () => SettingsView() },
|
||||
];
|
||||
|
||||
@ -17,6 +17,7 @@ export const settings = createStore('pdx.settings', {
|
||||
accent: 'red', // red | blue | green | amber | violet | rose
|
||||
spriteStyle: 'official',
|
||||
showShiny: false,
|
||||
showTeamNav: true,
|
||||
locale: 'en',
|
||||
});
|
||||
|
||||
|
||||
28
src/store/team.js
Normal file
28
src/store/team.js
Normal file
@ -0,0 +1,28 @@
|
||||
import { createStore } from './createStore.js';
|
||||
|
||||
/**
|
||||
* The working "lineup" — up to 6 Pokémon (national dex ids) analysed by the
|
||||
* Team view (coverage) and Compare view. Persisted.
|
||||
*/
|
||||
export const team = createStore('pdx.team', { members: [] });
|
||||
|
||||
export const MAX_TEAM = 6;
|
||||
|
||||
export function addToTeam(id) {
|
||||
team.set((s) => {
|
||||
if (s.members.includes(id) || s.members.length >= MAX_TEAM) return s;
|
||||
return { ...s, members: [...s.members, id] };
|
||||
});
|
||||
}
|
||||
|
||||
export function removeFromTeam(id) {
|
||||
team.set((s) => ({ ...s, members: s.members.filter((x) => x !== id) }));
|
||||
}
|
||||
|
||||
export function clearTeam() {
|
||||
team.set({ members: [] });
|
||||
}
|
||||
|
||||
export function inTeam(id) {
|
||||
return team.get().members.includes(id);
|
||||
}
|
||||
@ -16,4 +16,6 @@ export const ui = createStore('pdx.ui', {
|
||||
filterType: '',
|
||||
filterGen: 0,
|
||||
recent: [],
|
||||
toolsOpen: false,
|
||||
teamMode: 'coverage',
|
||||
});
|
||||
|
||||
@ -2008,3 +2008,269 @@
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= Team / Compare ================================= */
|
||||
.lineup {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
margin: 14px 0 22px;
|
||||
}
|
||||
.lineup__slot {
|
||||
position: relative;
|
||||
width: 88px;
|
||||
padding: 8px 6px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
text-align: center;
|
||||
}
|
||||
.lineup__x {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.lineup__link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.lineup__link .sprite {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.lineup__name {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: capitalize;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lineup__add {
|
||||
width: 88px;
|
||||
min-height: 106px;
|
||||
border: 1.5px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 1.6rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.lineup__add span {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.lineup__clear {
|
||||
align-self: center;
|
||||
border: none;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-dim);
|
||||
border-radius: 999px;
|
||||
padding: 6px 14px;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ---- Coverage table -------------------------------------------- */
|
||||
.cov {
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.cov__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: max-content;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.cov__row--head {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.cov__type {
|
||||
width: 74px;
|
||||
flex: none;
|
||||
}
|
||||
.cov__mem {
|
||||
width: 40px;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.cov__mem .sprite {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.cov__cell {
|
||||
width: 40px;
|
||||
flex: none;
|
||||
text-align: center;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
padding: 5px 0;
|
||||
border-radius: 6px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.cov__cell.m4 { background: color-mix(in srgb, var(--danger) 60%, transparent); color: #fff; }
|
||||
.cov__cell.m2 { background: color-mix(in srgb, var(--danger) 28%, transparent); }
|
||||
.cov__cell.m05 { background: color-mix(in srgb, var(--good) 24%, transparent); }
|
||||
.cov__cell.m025 { background: color-mix(in srgb, var(--good) 48%, transparent); color: #fff; }
|
||||
.cov__cell.m0 { background: var(--surface-2); color: var(--text-dim); }
|
||||
.cov__sum {
|
||||
width: 42px;
|
||||
flex: none;
|
||||
text-align: center;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.cov__sum.is-bad {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ---- Compare table ------------------------------------------- */
|
||||
.cmp {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.cmp__row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
min-width: max-content;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.cmp__row--head {
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
.cmp__row--bst {
|
||||
font-weight: 800;
|
||||
border-bottom: none;
|
||||
}
|
||||
.cmp__label {
|
||||
width: 72px;
|
||||
flex: none;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
align-self: center;
|
||||
}
|
||||
.cmp__mem {
|
||||
width: 96px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: capitalize;
|
||||
text-align: center;
|
||||
}
|
||||
.cmp__mem .sprite {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.cmp__cell {
|
||||
width: 96px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.cmp__stat {
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.cmp__val {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.cmp__stat.is-max .cmp__val {
|
||||
color: var(--good);
|
||||
}
|
||||
.cmp__track {
|
||||
width: 72px;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
.cmp__bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--type-main, var(--accent));
|
||||
}
|
||||
.cmp__stat.is-max .cmp__bar {
|
||||
background: var(--good);
|
||||
}
|
||||
|
||||
/* ---- Picker + team button ---------------------------------- */
|
||||
.picker__list {
|
||||
overflow-y: auto;
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.picker__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.picker__row:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.picker__row .sprite {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: none;
|
||||
object-fit: contain;
|
||||
}
|
||||
.ptrack__btn--team.is-on {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
.ptrack__btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import { getPokemon, getSpecies, getEvolutionChain, getEncounters } from '../dat
|
||||
import { settings } from '../store/settings.js';
|
||||
import { ui } from '../store/ui.js';
|
||||
import { entry, toggle } from '../store/selection.js';
|
||||
import { team, addToTeam, removeFromTeam, inTeam, MAX_TEAM } from '../store/team.js';
|
||||
import { Sprite } from '../components/Sprite.js';
|
||||
import { TypeChip } from '../components/TypeChip.js';
|
||||
import { StatBar } from '../components/StatBar.js';
|
||||
@ -209,6 +210,24 @@ export async function PokemonDetail(nationalId) {
|
||||
b.append(label);
|
||||
return b;
|
||||
}
|
||||
|
||||
const teamBtn = el('button', {
|
||||
class: 'ptrack__btn ptrack__btn--team',
|
||||
type: 'button',
|
||||
onclick: () => {
|
||||
if (inTeam(nationalId)) removeFromTeam(nationalId);
|
||||
else addToTeam(nationalId);
|
||||
syncTeamBtn();
|
||||
},
|
||||
});
|
||||
function syncTeamBtn() {
|
||||
const on = inTeam(nationalId);
|
||||
const full = team.get().members.length >= MAX_TEAM;
|
||||
teamBtn.textContent = on ? '✓ In team' : full ? 'Team full' : '+ Team';
|
||||
teamBtn.classList.toggle('is-on', on);
|
||||
teamBtn.disabled = !on && full;
|
||||
}
|
||||
syncTeamBtn();
|
||||
function syncTrack() {
|
||||
for (const [field, b] of [
|
||||
['favorite', favBtn],
|
||||
@ -433,7 +452,7 @@ export async function PokemonDetail(nationalId) {
|
||||
el(
|
||||
'div',
|
||||
{ class: 'psheet' },
|
||||
el('div', { class: 'ptrack' }, seenBtn, caughtBtn),
|
||||
el('div', { class: 'ptrack' }, seenBtn, caughtBtn, teamBtn),
|
||||
el('div', { class: 'psheet__tabs', role: 'tablist' }, ...tabButtons),
|
||||
body,
|
||||
),
|
||||
@ -448,8 +467,10 @@ export async function PokemonDetail(nationalId) {
|
||||
});
|
||||
|
||||
const off = settings.subscribe(() => syncTrack());
|
||||
const offTeam = team.subscribe(syncTeamBtn);
|
||||
onTeardown(view, () => {
|
||||
off();
|
||||
offTeam();
|
||||
offSwipe();
|
||||
});
|
||||
return view;
|
||||
|
||||
@ -63,6 +63,15 @@ export async function SettingsView() {
|
||||
'Default detail view to shiny sprites',
|
||||
);
|
||||
|
||||
const teamNavField = el('label', { class: 'field field--check' },
|
||||
el('input', {
|
||||
type: 'checkbox',
|
||||
checked: st.showTeamNav !== false,
|
||||
onchange: (e) => settings.set({ showTeamNav: e.target.checked }),
|
||||
}),
|
||||
'Show Team in the navigation bar',
|
||||
);
|
||||
|
||||
const storageNote = el('p', { class: 'settings__note' }, 'Calculating storage…');
|
||||
if (navigator.storage?.estimate) {
|
||||
navigator.storage.estimate().then(({ usage = 0, quota = 0 }) => {
|
||||
@ -93,7 +102,7 @@ export async function SettingsView() {
|
||||
|
||||
view.append(
|
||||
el('header', { class: 'view__header' }, el('h1', {}, 'Settings')),
|
||||
el('div', { class: 'settings__group' }, themeField, accentField, spriteField, shinyField),
|
||||
el('div', { class: 'settings__group' }, themeField, accentField, spriteField, shinyField, teamNavField),
|
||||
|
||||
el('h2', {}, 'Your data'),
|
||||
storageNote,
|
||||
|
||||
278
src/views/TeamView.js
Normal file
278
src/views/TeamView.js
Normal file
@ -0,0 +1,278 @@
|
||||
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 { team, addToTeam, removeFromTeam, clearTeam, MAX_TEAM } from '../store/team.js';
|
||||
import { TYPES, defenseVector, multiplier } from '../data/type-chart.js';
|
||||
import { Sprite } from '../components/Sprite.js';
|
||||
import { TypeChip } from '../components/TypeChip.js';
|
||||
import { openPokemonPicker } from '../components/PokemonPicker.js';
|
||||
|
||||
const prettify = (s) => s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
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}×`);
|
||||
|
||||
export async function TeamView() {
|
||||
const view = el('section', { class: 'view teamview' });
|
||||
const snap = await loadSnapshot();
|
||||
let mode = ui.get().teamMode === 'compare' ? 'compare' : 'coverage';
|
||||
|
||||
const seg = el(
|
||||
'div',
|
||||
{ class: 'seg' },
|
||||
...[
|
||||
['coverage', 'Coverage'],
|
||||
['compare', 'Compare'],
|
||||
].map(([id, label]) =>
|
||||
el(
|
||||
'button',
|
||||
{
|
||||
class: `seg__btn${id === mode ? ' is-active' : ''}`,
|
||||
type: 'button',
|
||||
onclick: () => {
|
||||
mode = id;
|
||||
ui.set({ teamMode: id });
|
||||
[...seg.children].forEach((b, i) =>
|
||||
b.classList.toggle('is-active', ['coverage', 'compare'][i] === id),
|
||||
);
|
||||
render();
|
||||
},
|
||||
},
|
||||
label,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const lineup = el('div', { class: 'lineup' });
|
||||
const body = el('div', { class: 'teamview__body' });
|
||||
|
||||
view.append(
|
||||
el(
|
||||
'header',
|
||||
{ class: 'view__header' },
|
||||
el('h1', {}, 'Team'),
|
||||
el('p', {}, `Up to ${MAX_TEAM} Pokémon — check weaknesses, coverage and stats.`),
|
||||
),
|
||||
seg,
|
||||
lineup,
|
||||
body,
|
||||
);
|
||||
|
||||
function members() {
|
||||
return team.get().members.map((id) => snap.speciesById.get(id)).filter(Boolean);
|
||||
}
|
||||
|
||||
function renderLineup() {
|
||||
const mem = members();
|
||||
clear(lineup);
|
||||
const style = settings.get().spriteStyle;
|
||||
for (const sp of mem) {
|
||||
lineup.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'lineup__slot' },
|
||||
el(
|
||||
'button',
|
||||
{
|
||||
class: 'lineup__x',
|
||||
type: 'button',
|
||||
'aria-label': `Remove ${sp.name}`,
|
||||
onclick: () => {
|
||||
removeFromTeam(sp.id);
|
||||
},
|
||||
},
|
||||
'✕',
|
||||
),
|
||||
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_TEAM) {
|
||||
lineup.append(
|
||||
el(
|
||||
'button',
|
||||
{
|
||||
class: 'lineup__add',
|
||||
type: 'button',
|
||||
onclick: () => openPokemonPicker(addToTeam),
|
||||
},
|
||||
'+',
|
||||
el('span', {}, 'Add'),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (mem.length) {
|
||||
lineup.append(
|
||||
el(
|
||||
'button',
|
||||
{ class: 'lineup__clear', type: 'button', onclick: () => clearTeam() },
|
||||
'Clear',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderLineup();
|
||||
const mem = members();
|
||||
clear(body);
|
||||
if (!mem.length) {
|
||||
body.append(el('p', { class: 'detail__muted' }, 'Add some Pokémon to get started.'));
|
||||
return;
|
||||
}
|
||||
body.append(mode === 'coverage' ? coverage(mem) : compare(mem));
|
||||
}
|
||||
|
||||
function coverage(mem) {
|
||||
const gen = 9;
|
||||
const vectors = mem.map((sp) => defenseVector(sp.types, gen));
|
||||
|
||||
// Defensive weakness table
|
||||
const head = el(
|
||||
'div',
|
||||
{ class: 'cov__row cov__row--head' },
|
||||
el('span', {}, ''),
|
||||
...mem.map((sp) => el('span', { class: 'cov__mem' }, Sprite(sp.id, { size: 34, alt: sp.name }))),
|
||||
el('span', { class: 'cov__sum' }, 'Weak'),
|
||||
);
|
||||
const rows = TYPES.map((t) => {
|
||||
let weak = 0;
|
||||
const cells = vectors.map((v) => {
|
||||
const m = v[t] ?? 1;
|
||||
if (m >= 2) weak += 1;
|
||||
return el('span', { class: `cov__cell ${multClass(m)}` }, multText(m));
|
||||
});
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'cov__row' },
|
||||
el('span', { class: 'cov__type' }, TypeChip(t)),
|
||||
...cells,
|
||||
el('span', { class: `cov__sum${weak >= 2 ? ' is-bad' : ''}` }, weak || ''),
|
||||
);
|
||||
});
|
||||
|
||||
// Offensive coverage gaps: defending types no member hits super-effectively
|
||||
const gaps = TYPES.filter((def) => {
|
||||
if (gen < 6 && def === 'fairy') return false;
|
||||
return !mem.some((sp) => sp.types.some((atk) => multiplier(atk, [def], gen) >= 2));
|
||||
});
|
||||
|
||||
// Quick team stats
|
||||
const avgBst = Math.round(mem.reduce((s, m) => s + (m.bst || 0), 0) / mem.length);
|
||||
const fastest = mem.slice().sort((a, b) => (b.stats?.spe || 0) - (a.stats?.spe || 0))[0];
|
||||
const bulkiest = mem
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(b.stats?.hp || 0) + (b.stats?.def || 0) + (b.stats?.spd || 0) -
|
||||
((a.stats?.hp || 0) + (a.stats?.def || 0) + (a.stats?.spd || 0)),
|
||||
)[0];
|
||||
|
||||
return el(
|
||||
'div',
|
||||
{},
|
||||
el('h2', { class: 'ppanel__sub' }, 'Type coverage — how each type hits your team'),
|
||||
el('div', { class: 'cov' }, head, ...rows),
|
||||
el('h2', { class: 'ppanel__sub' }, 'Offensive gaps (no super-effective STAB)'),
|
||||
gaps.length
|
||||
? el('div', { class: 'matchups__types' }, ...gaps.map(TypeChip))
|
||||
: el('p', { class: 'detail__muted' }, 'Your team hits every type super-effectively. Nice.'),
|
||||
el('h2', { class: 'ppanel__sub' }, 'At a glance'),
|
||||
el(
|
||||
'dl',
|
||||
{ class: 'pfacts' },
|
||||
fact('Average BST', String(avgBst)),
|
||||
fact('Fastest', `${prettify(fastest.name)} (${fastest.stats?.spe ?? '?'} Spe)`),
|
||||
fact(
|
||||
'Bulkiest',
|
||||
`${prettify(bulkiest.name)} (${
|
||||
(bulkiest.stats?.hp || 0) + (bulkiest.stats?.def || 0) + (bulkiest.stats?.spd || 0)
|
||||
} HP+Def+SpD)`,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function compare(mem) {
|
||||
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' }, ...sp.types.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',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
render();
|
||||
const off = team.subscribe(render);
|
||||
const offStyle = settings.subscribe(renderLineup);
|
||||
onTeardown(view, () => {
|
||||
off();
|
||||
offStyle();
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
function fact(k, v) {
|
||||
return el('div', { class: 'fact' }, el('dt', {}, k), el('dd', {}, v));
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user