diff --git a/README.md b/README.md index b6976ce..1e30c0d 100644 --- a/README.md +++ b/README.md @@ -93,8 +93,13 @@ Working app with a type-themed UI: this game" and sort by power / accuracy / recency; Items filter by category. Each has its own detail page. Query, scroll, tab and filters persist. -- **Team** — a lineup of up to 6 with a Coverage table (weaknesses, STAB - gaps) and a Compare table; toggleable in the nav from Settings. +- **Team** — a lineup of up to 6. Coverage leads with a "weak spots" + summary (types that hit 2+ members, or that someone's weak to and nobody + resists), then a full matchup grid with a diverging resist◀│▶weak bar per + type; plus offensive STAB gaps and at-a-glance stats. A Compare table + stacks the six side by side. All matchup maths follow the selected game — + its generation's type chart and era-accurate typings (pre-Gen-6 Clefairy + is Normal, etc.). Toggleable in the nav from Settings. - **Settings** — theme (System / Light / Dark / Black / Sepia) + accent colour, sprite style, JSON export/import. - **PWA** — Workbox SW: precache shell + snapshot, SWR for API JSON, diff --git a/src/styles/layout.css b/src/styles/layout.css index 2c9b83f..e8fd803 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -2139,18 +2139,128 @@ .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; +.cov__row--threat { + background: color-mix(in srgb, var(--danger) 12%, transparent); + box-shadow: inset 3px 0 0 var(--danger); + border-radius: 6px; +} +/* diverging bar: resists grow left of centre, weaknesses grow right */ +.cov__bal { + width: 138px; flex: none; - text-align: center; - font-size: 0.78rem; + display: flex; + align-items: center; +} +.cov__bal--head { + font-size: 0.66rem; font-weight: 700; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.03em; + justify-content: center; } -.cov__sum.is-bad { - color: var(--danger); +.cov__bal-side { + flex: 1; + display: flex; + align-items: center; +} +.cov__bal-side--l { + justify-content: flex-end; + border-right: 1px solid var(--border); + padding-right: 3px; +} +.cov__bal-side--r { + padding-left: 3px; +} +.cov__bal-res, +.cov__bal-weak { + height: 12px; + border-radius: 3px; + min-width: 0; +} +.cov__bal-res { + background: var(--good); +} +.cov__bal-weak { + background: var(--danger); + color: #fff; + font-size: 0.7rem; + font-weight: 800; + line-height: 12px; + text-align: center; +} + +/* ---- Weak-spots summary ------------------------------------- */ +.cov__ok { + color: var(--good); + font-weight: 600; + margin: 4px 0 2px; +} +.weakspots { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 6px; +} +.weakspot { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 7px 10px; + border-radius: 12px; + background: color-mix(in srgb, var(--danger) 10%, var(--surface-2)); +} +.weakspot.is-nores { + background: color-mix(in srgb, var(--danger) 20%, var(--surface-2)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--danger) 45%, transparent); +} +.weakspot__count { + font-weight: 800; + font-size: 0.8rem; +} +.weakspot__flag { + font-size: 0.68rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #fff; + background: var(--danger); + padding: 2px 6px; + border-radius: 999px; +} +.weakspot__res { + font-size: 0.72rem; + color: var(--text-dim); + font-weight: 600; +} +.weakspot__mem { + display: flex; + gap: 3px; + margin-left: auto; +} +.weakspot__chip { + width: 26px; + height: 26px; + display: grid; + place-items: center; +} +.weakspot__chip .sprite { + width: 26px; + height: 26px; + object-fit: contain; +} +.cov__unres { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + margin: 2px 0 4px; +} +.cov__unres-k { + font-size: 0.78rem; + font-weight: 700; + color: var(--text-dim); } /* ---- Compare table ------------------------------------------- */ diff --git a/src/views/TeamView.js b/src/views/TeamView.js index db5e731..f244313 100644 --- a/src/views/TeamView.js +++ b/src/views/TeamView.js @@ -4,6 +4,7 @@ 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 { typesForGen } from '../lib/type-resolve.js'; import { Sprite } from '../components/Sprite.js'; import { TypeChip } from '../components/TypeChip.js'; import { openPokemonPicker } from '../components/PokemonPicker.js'; @@ -143,38 +144,124 @@ export async function TeamView() { } function coverage(mem) { - const gen = 9; - const vectors = mem.map((sp) => defenseVector(sp.types, gen)); + 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; - // Defensive weakness table + // 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.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', {}, ''), - ...mem.map((sp) => el('span', { class: 'cov__mem' }, Sprite(sp.id, { size: 34, alt: sp.name }))), - el('span', { class: 'cov__sum' }, 'Weak'), + el('span', { class: 'cov__type' }, ''), + ...mem.map((sp) => + el( + 'a', + { class: 'cov__mem', href: `#/pokemon/${sp.id}`, title: prettify(sp.name) }, + Sprite(sp.id, { style, size: 34, alt: sp.name }), + ), + ), + el('span', { class: 'cov__bal cov__bal--head' }, 'resist · weak'), ); - const rows = TYPES.map((t) => { - let weak = 0; + const rows = GEN_TYPES.map((t) => { + const a = analysis.find((x) => x.type === t); 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)); }); + 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' }, + { class: `cov__row${a.weakN >= 2 ? ' cov__row--threat' : ''}` }, el('span', { class: 'cov__type' }, TypeChip(t)), ...cells, - el('span', { class: `cov__sum${weak >= 2 ? ' is-bad' : ''}` }, weak || ''), + bal, ); }); // 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)); - }); + 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); @@ -190,7 +277,17 @@ export async function TeamView() { return el( 'div', {}, - el('h2', { class: 'ppanel__sub' }, 'Type coverage — how each type hits your team'), + 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 @@ -213,6 +310,8 @@ export async function TeamView() { } function compare(mem) { + const vg = snap.versionGroupByKey.get(settings.get().versionGroup); + const gen = vg ? vg.generation : 9; 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); @@ -234,7 +333,7 @@ export async function TeamView() { ), row( 'Type', - mem.map((sp) => el('span', { class: 'cmp__cell' }, ...sp.types.map(TypeChip))), + mem.map((sp) => el('span', { class: 'cmp__cell' }, ...typesForGen(sp, gen).map(TypeChip))), ), ...STAT_ROWS.map(([key, label]) => { const mx = maxOf(key); @@ -270,10 +369,12 @@ export async function TeamView() { render(); const off = team.subscribe(render); - const offStyle = settings.subscribe(renderLineup); + // 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(); - offStyle(); + offSettings(); }); return view; }