dex/src/views/TeamView.js
chris 671e72972a Apply Prettier to the whole tree
Pure formatting — no behaviour change. Verified: build passes, all 14
routes render, service worker active, no console errors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-10 11:24:31 -04:00

741 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, slot, MAX_TEAM } from '../store/team.js';
import { TYPES, defenseVector, multiplier } from '../data/type-chart.js';
import { typesForGen } from '../lib/type-resolve.js';
import { forGeneration } from '../components/MovesList.js';
import { getMove } from '../data/api.js';
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';
import { segThumb } from '../lib/anim.js';
const prettify = (s) => s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const loose = (s) =>
s
.toLowerCase()
.replace(/[-\s]+/g, ' ')
.trim();
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}×`);
const effWord = (eff) =>
eff === 0
? 'No effect'
: eff >= 4
? '4× effective'
: eff >= 2
? 'Super effective'
: eff <= 0.25
? 'Barely effective'
: eff <= 0.5
? 'Not very effective'
: null;
export async function TeamView() {
const view = el('section', { class: 'view teamview' });
const snap = await loadSnapshot();
const MODES = [
['coverage', 'Coverage'],
['compare', 'Compare'],
['calc', 'Calc'],
];
let mode = MODES.some(([id]) => id === ui.get().teamMode) ? ui.get().teamMode : 'coverage';
const seg = el(
'div',
{ class: 'seg' },
...MODES.map(([id, label], i) =>
el(
'button',
{
class: `seg__btn${id === mode ? ' is-active' : ''}`,
type: 'button',
onclick: () => {
mode = id;
ui.set({ teamMode: id });
seg
.querySelectorAll('.seg__btn')
.forEach((b, j) => b.classList.toggle('is-active', MODES[j][0] === id));
setThumb(i);
render();
},
},
label,
),
),
);
const setThumb = segThumb(
seg,
MODES.length,
Math.max(
0,
MODES.findIndex(([id]) => id === mode),
),
);
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.`),
),
el(
'div',
{ class: 'teamview__bar' },
seg,
el(
'nav',
{ class: 'teamview__refs', 'aria-label': 'Reference tools' },
el(
'a',
{ class: 'reflink', href: '#/natures' },
el('span', { class: 'reflink__ico', 'aria-hidden': 'true' }, '✦'),
'Natures',
),
el(
'a',
{ class: 'reflink', href: '#/types' },
el('span', { class: 'reflink__ico', 'aria-hidden': 'true' }, '▦'),
'Type chart',
),
el(
'a',
{ class: 'reflink', href: '#/breeding' },
el('span', { class: 'reflink__ico', 'aria-hidden': 'true' }, '⬡'),
'Breeding',
),
),
),
lineup,
body,
);
function members() {
return team
.get()
.members.map((m) => {
const s = slot(m);
const base = snap.speciesById.get(s.id);
if (!base) return null;
const f = s.form && (base.forms || []).find((x) => x.slug === s.form);
if (!f) return { ...base, _slotForm: null };
// Merge the snapshot form's baked types / stats so coverage,
// compare and the calc all analyse the form, not the base.
return {
...base,
_slotForm: s.form,
name: `${base.name} (${f.name})`,
spriteId: f.id,
types: f.types || base.types,
pastTypes: [],
stats: f.stats || base.stats,
bst: f.bst ?? base.bst,
};
})
.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, sp._slotForm);
},
},
'✕',
),
el(
'a',
{ href: `#/pokemon/${sp.id}`, class: 'lineup__link' },
Sprite(sp.spriteId ?? 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((id) => addToTeam(id), { closeAfterPick: true }),
},
'',
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 (mode === 'calc') {
body.append(calcTool(mem));
return;
}
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 st = settings.get();
const vg = snap.versionGroupByKey.get(st.versionGroup);
const gen = vg ? vg.generation : 9;
const gameLabel = st.versionGroup === 'all' ? 'all games' : prettify(st.versionGroup);
const style = st.spriteStyle;
// Only the types that exist in the selected game (no Fairy before Gen 6,
// no Steel/Dark before Gen 2). Member typings are era-accurate too —
// pre-Gen-6 Clefairy is Normal, Gen 1 Magnemite is pure Electric.
const GEN_TYPES = TYPES.filter((t) => {
if (t === 'fairy' && gen < 6) return false;
if ((t === 'steel' || t === 'dark') && gen < 2) return false;
return true;
});
const memTypes = mem.map((sp) => typesForGen(sp, gen));
const vectors = memTypes.map((types) => defenseVector(types, gen));
// Per attacking type: which members are weak, how many resist.
const analysis = GEN_TYPES.map((t) => {
const weak = [];
let resist = 0;
vectors.forEach((v, i) => {
const m = v[t] ?? 1;
if (m >= 2) weak.push({ sp: mem[i], m });
else if (m < 1) resist += 1;
});
return { type: t, weak, weakN: weak.length, resist };
});
// A "weak spot": 2+ members weak, or someone weak and nobody resists it.
const threats = analysis
.filter((a) => a.weakN >= 2 || (a.weakN >= 1 && a.resist === 0))
.sort((a, b) => b.weakN - a.weakN || a.resist - b.resist);
const unresisted = analysis.filter((a) => a.weakN === 0 && a.resist === 0).map((a) => a.type);
const weakSpots = threats.length
? el(
'div',
{ class: 'weakspots' },
...threats.map((a) =>
el(
'div',
{ class: `weakspot${a.resist === 0 ? ' is-nores' : ''}` },
TypeChip(a.type),
el('span', { class: 'weakspot__count' }, `${a.weakN} weak`),
a.resist === 0
? el('span', { class: 'weakspot__flag' }, '0 resist')
: el('span', { class: 'weakspot__res' }, `${a.resist} resist`),
el(
'span',
{ class: 'weakspot__mem' },
...a.weak.map(({ sp, m }) =>
el(
'a',
{
class: 'weakspot__chip',
href: `#/pokemon/${sp.id}`,
title: `${prettify(sp.name)} takes ${m}×`,
},
Sprite(sp.spriteId ?? sp.id, { style, size: 26, alt: sp.name }),
),
),
),
),
),
)
: el('p', { class: 'cov__ok' }, '✓ Nothing hits two or more of your team super-effectively.');
// Defensive grid: a diverging bar (resists ◀ | ▶ weaknesses) makes the
// rows to worry about jump out; threat rows get a tinted background.
const head = el(
'div',
{ class: 'cov__row cov__row--head' },
el('span', { class: 'cov__type' }, ''),
...mem.map((sp) =>
el(
'a',
{ class: 'cov__mem', href: `#/pokemon/${sp.id}`, title: prettify(sp.name) },
Sprite(sp.spriteId ?? sp.id, { style, size: 34, alt: sp.name }),
),
),
el('span', { class: 'cov__bal cov__bal--head' }, 'resist · weak'),
);
const rows = GEN_TYPES.map((t) => {
const a = analysis.find((x) => x.type === t);
const cells = vectors.map((v) => {
const m = v[t] ?? 1;
return el('span', { class: `cov__cell ${multClass(m)}` }, multText(m));
});
const bal = el(
'span',
{ class: 'cov__bal' },
el(
'span',
{ class: 'cov__bal-side cov__bal-side--l' },
el('span', { class: 'cov__bal-res', style: `width:${Math.min(a.resist, 6) * 10}px` }),
),
el(
'span',
{ class: 'cov__bal-side cov__bal-side--r' },
el(
'span',
{ class: 'cov__bal-weak', style: `width:${Math.min(a.weakN, 6) * 10}px` },
a.weakN || '',
),
),
);
return el(
'div',
{ class: `cov__row${a.weakN >= 2 ? ' cov__row--threat' : ''}` },
el('span', { class: 'cov__type' }, TypeChip(t)),
...cells,
bal,
);
});
// Offensive coverage gaps: defending types no member hits super-effectively
const gaps = GEN_TYPES.filter(
(def) => !mem.some((sp, i) => memTypes[i].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' }, `Weak spots · ${gameLabel} (Gen ${gen})`),
weakSpots,
unresisted.length
? el(
'p',
{ class: 'cov__unres' },
el('span', { class: 'cov__unres-k' }, 'No resist to: '),
el('span', { class: 'matchups__types' }, ...unresisted.map(TypeChip)),
)
: null,
el('h2', { class: 'ppanel__sub' }, 'Full matchup grid'),
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', monLink(fastest, `${fastest.stats?.spe ?? '?'} Spe`)),
fact(
'Bulkiest',
monLink(
bulkiest,
`${(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 el(
'div',
{ class: 'teamcompare' },
el(
'p',
{ class: 'teamcompare__note' },
'Your six side by side. ',
el('a', { class: 'link', href: '#/compare' }, 'Compare any Pokémon →'),
),
CompareTable(mem, { gen, style: settings.get().spriteStyle }),
);
}
// ---- Damage calculator ------------------------------------------
// Attacker + move vs. defender -> a damage range, using the standard
// formula off base stats/level/nature/EVs. No items, abilities, weather
// or terrain — those aren't part of this app's data model, so it's a
// base-stats estimate rather than a battle-exact number.
function calcTool(mem) {
const st = settings.get();
const vg = snap.versionGroupByKey.get(st.versionGroup);
const gen = vg ? vg.generation : 9;
const genOfVg = (name) => snap.versionGroupByKey.get(name)?.generation ?? 9;
const style = st.spriteStyle;
const state = {
atk: mem[0] || null,
def: mem[1] || mem[0] || null,
move: null, // { id, name, ...current-gen snapshot fields }
moveFull: null, // live /move/:id once fetched (adds past_values)
level: 100,
atkNature: 'Hardy',
defNature: 'Hardy',
atkEv: 0,
defEv: 0,
hpEv: 0,
crit: false,
};
const natureOptions = NATURES.map((n) => [n.name, n.name]);
function numField(label, value, { min = 0, max = 999 } = {}, onChange) {
return el(
'label',
{ class: 'calc__field' },
el('span', {}, label),
el('input', {
type: 'number',
min: String(min),
max: String(max),
value: String(value),
onchange: (e) => {
const n = Math.max(min, Math.min(max, Number(e.target.value) || 0));
e.target.value = String(n);
onChange(n);
recalc();
},
}),
);
}
function natureField(label, value, onChange) {
return el(
'label',
{ class: 'calc__field' },
el('span', {}, label),
el(
'select',
{
onchange: (e) => {
onChange(e.target.value);
recalc();
},
},
...natureOptions.map(([v, t]) => el('option', { value: v, selected: v === value }, t)),
),
);
}
function slot(which, sideLabel) {
const holder = el('div', { class: 'calc__slot' });
function paint() {
const sp = state[which];
clear(holder).append(
sp
? el(
'button',
{ type: 'button', class: 'calc__pick', onclick: () => pick() },
Sprite(sp.spriteId ?? sp.id, { style, size: 48, alt: sp.name }),
el('span', {}, prettify(sp.name)),
)
: el(
'button',
{ type: 'button', class: 'calc__pick calc__pick--empty', onclick: () => pick() },
`Choose ${sideLabel}`,
),
);
}
function pick() {
openPokemonPicker((id) => {
state[which] = snap.speciesById.get(id);
paint();
recalc();
});
}
paint();
const quick = mem.length
? el(
'div',
{ class: 'calc__quick' },
...mem.map((sp) =>
el(
'button',
{
type: 'button',
class: 'calc__quick-btn',
title: prettify(sp.name),
onclick: () => {
state[which] = sp;
paint();
recalc();
},
},
Sprite(sp.spriteId ?? sp.id, { style, size: 26, alt: sp.name }),
),
),
)
: null;
return el(
'div',
{ class: 'calc__side' },
el('h3', { class: 'ppanel__sub' }, sideLabel),
holder,
quick,
);
}
const atkSlot = slot('atk', 'Attacker');
const defSlot = slot('def', 'Defender');
const moveNote = el('span', { class: 'calc__move-note' });
const moveDatalist = el(
'datalist',
{ id: 'calc-movelist' },
...snap.moves.map((m) => el('option', { value: m.name.replace(/-/g, ' ') })),
);
const moveInput = el('input', {
type: 'text',
list: 'calc-movelist',
class: 'calc__move-input',
placeholder: 'Move…',
onchange: (e) => {
const q = loose(e.target.value);
const hit = snap.moves.find((m) => loose(m.name) === q);
state.move = hit || null;
state.moveFull = null;
e.target.value = hit ? hit.name.replace(/-/g, ' ') : '';
recalc();
if (hit) {
getMove(hit.id)
.then((full) => {
if (state.move?.id !== hit.id) return; // superseded
state.moveFull = full;
recalc();
})
.catch(() => {});
}
},
});
const critField = el(
'label',
{ class: 'calc__field calc__field--check' },
el('input', {
type: 'checkbox',
onchange: (e) => {
state.crit = e.target.checked;
recalc();
},
}),
'Critical hit',
);
const result = el('div', { class: 'calc__result' });
function recalc() {
const { atk, def } = state;
moveNote.textContent = '';
if (!state.move) {
clear(result).append(el('p', { class: 'detail__muted' }, 'Pick a move.'));
return;
}
const raw = state.moveFull || {
power: state.move.power,
accuracy: state.move.accuracy,
type: { name: state.move.type },
damage_class: state.move.damageClass ? { name: state.move.damageClass } : null,
past_values: [],
};
const v = forGeneration(raw, gen, genOfVg);
moveNote.textContent = `${prettify(v.type)} · ${v.damage_class ? prettify(v.damage_class) : 'Status'}${v.power ? ` · ${v.power} power` : ''}`;
if (!atk || !def) {
clear(result).append(
el('p', { class: 'detail__muted' }, 'Choose an attacker and a defender.'),
);
return;
}
if (!v.damage_class || v.damage_class === 'status' || !v.power) {
clear(result).append(
el(
'p',
{ class: 'detail__muted' },
`${prettify(state.move.name)} doesn't deal direct damage.`,
),
);
return;
}
const statKey = v.damage_class === 'special' ? 'spa' : 'atk';
const defKey = v.damage_class === 'special' ? 'spd' : 'def';
const atkTypes = typesForGen(atk, gen);
const defTypes = typesForGen(def, gen);
const atkStat = statAt(atk.stats?.[statKey] ?? 0, state.level, {
ev: state.atkEv,
nature: state.atkNature,
statKey,
});
const defStat = statAt(def.stats?.[defKey] ?? 0, state.level, {
ev: state.defEv,
nature: state.defNature,
statKey: defKey,
});
const defHp = statAt(def.stats?.hp ?? 0, state.level, { ev: state.hpEv, statKey: 'hp' });
const dmg = calcDamage({
level: state.level,
power: v.power,
moveType: v.type,
atkTypes,
defTypes,
atkStat,
defStat,
gen,
crit: state.crit,
});
clear(result);
if (!dmg) {
result.append(el('p', { class: 'detail__muted' }, 'No power data for this move.'));
return;
}
if (dmg.eff === 0) {
result.append(
el('p', { class: 'calc__headline' }, `${prettify(def.name)} is immune — no effect.`),
);
return;
}
const pctMin = (dmg.min / defHp) * 100;
const pctMax = (dmg.max / defHp) * 100;
const hitsMin = Math.ceil(100 / pctMax);
const hitsMax = Math.ceil(100 / pctMin);
const word = effWord(dmg.eff);
result.append(
el(
'p',
{ class: 'calc__headline' },
`${dmg.min}${dmg.max} dmg`,
el('span', { class: 'calc__pct' }, ` · ${pctMin.toFixed(1)}${pctMax.toFixed(1)}% HP`),
),
el(
'p',
{ class: 'calc__sub' },
hitsMin === hitsMax
? `${hitsMin} hit${hitsMin > 1 ? 's' : ''} to KO`
: `${hitsMin}${hitsMax} hits to KO`,
),
el(
'div',
{ class: 'calc__badges' },
dmg.stab ? el('span', { class: 'calc__badge calc__badge--stab' }, 'STAB') : null,
word
? el(
'span',
{
class: `calc__badge${dmg.eff >= 2 || dmg.eff === 4 ? ' calc__badge--up' : ' calc__badge--down'}`,
},
word,
)
: null,
),
);
}
recalc();
return el(
'div',
{ class: 'calc' },
el(
'div',
{ class: 'calc__sides' },
atkSlot,
el('span', { class: 'calc__vs', 'aria-hidden': 'true' }, '⇄'),
defSlot,
),
moveDatalist,
el('div', { class: 'calc__movebar' }, moveInput, moveNote),
el(
'div',
{ class: 'calc__controls' },
numField('Level', state.level, { min: 1, max: 100 }, (n) => (state.level = n)),
natureField('Attacker nature', state.atkNature, (v) => (state.atkNature = v)),
numField('Attacker EV', state.atkEv, { min: 0, max: 252 }, (n) => (state.atkEv = n)),
natureField('Defender nature', state.defNature, (v) => (state.defNature = v)),
numField('Defender EV', state.defEv, { min: 0, max: 252 }, (n) => (state.defEv = n)),
numField('Defender HP EV', state.hpEv, { min: 0, max: 252 }, (n) => (state.hpEv = n)),
critField,
),
result,
el(
'p',
{ class: 'calc__caveat' },
'Estimate from base stats (31 IVs assumed) — items, abilities, weather and terrain arent modeled.',
),
);
}
render();
const off = team.subscribe(render);
// Re-render on any settings change: sprite style feeds the lineup, and the
// selected game drives the coverage generation + era typings.
const offSettings = settings.subscribe(render);
onTeardown(view, () => {
off();
offSettings();
});
return view;
}
function fact(k, v) {
return el('div', { class: 'fact' }, el('dt', {}, k), el('dd', {}, v));
}