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:
chris 2026-08-28 10:33:52 -04:00
parent 5717b0a38a
commit ddc84c2a48
11 changed files with 746 additions and 25 deletions

View File

@ -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;
}

View 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'));
}

View File

@ -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

View File

@ -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() },
];

View File

@ -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
View 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);
}

View File

@ -16,4 +16,6 @@ export const ui = createStore('pdx.ui', {
filterType: '',
filterGen: 0,
recent: [],
toolsOpen: false,
teamMode: 'coverage',
});

View File

@ -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;
}

View File

@ -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;

View File

@ -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
View 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));
}