diff --git a/src/components/Nav.js b/src/components/Nav.js index 14739e1..c6dcce4 100644 --- a/src/components/Nav.js +++ b/src/components/Nav.js @@ -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; } diff --git a/src/components/PokemonPicker.js b/src/components/PokemonPicker.js new file mode 100644 index 0000000..eb3ec23 --- /dev/null +++ b/src/components/PokemonPicker.js @@ -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')); +} diff --git a/src/data/type-chart.js b/src/data/type-chart.js index 13fa2b0..f9ef7be 100644 --- a/src/data/type-chart.js +++ b/src/data/type-chart.js @@ -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 diff --git a/src/router.js b/src/router.js index c2f7dff..8e88137 100644 --- a/src/router.js +++ b/src/router.js @@ -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() }, ]; diff --git a/src/store/settings.js b/src/store/settings.js index 0f76b8b..232e232 100644 --- a/src/store/settings.js +++ b/src/store/settings.js @@ -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', }); diff --git a/src/store/team.js b/src/store/team.js new file mode 100644 index 0000000..3bed1ea --- /dev/null +++ b/src/store/team.js @@ -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); +} diff --git a/src/store/ui.js b/src/store/ui.js index ea17519..7d2b6e4 100644 --- a/src/store/ui.js +++ b/src/store/ui.js @@ -16,4 +16,6 @@ export const ui = createStore('pdx.ui', { filterType: '', filterGen: 0, recent: [], + toolsOpen: false, + teamMode: 'coverage', }); diff --git a/src/styles/layout.css b/src/styles/layout.css index be91e96..6436cac 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -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; +} diff --git a/src/views/PokemonDetail.js b/src/views/PokemonDetail.js index af02585..4b88957 100644 --- a/src/views/PokemonDetail.js +++ b/src/views/PokemonDetail.js @@ -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; diff --git a/src/views/SettingsView.js b/src/views/SettingsView.js index 47af97b..8a89e6d 100644 --- a/src/views/SettingsView.js +++ b/src/views/SettingsView.js @@ -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, diff --git a/src/views/TeamView.js b/src/views/TeamView.js new file mode 100644 index 0000000..7f7b7f4 --- /dev/null +++ b/src/views/TeamView.js @@ -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)); +}