Add a standalone interactive type chart page

New #/types route (linked from the Team header alongside Natures): the
full 18x18 attack/defend grid, gen-aware from the selected game — drops
Fairy pre-Gen 6, Steel/Dark pre-Gen 2, and applies the Gen 1 / pre-Gen 6
quirks the type-chart module already models. Tap a type on either edge to
highlight its row+column and get an offensive + defensive breakdown as
type chips.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
chris 2026-09-09 18:12:31 -04:00
parent 262e1a454e
commit cfa30c987f
5 changed files with 252 additions and 2 deletions

View File

@ -118,7 +118,9 @@ Working app with a type-themed UI:
no items/abilities/weather/terrain). All three follow the selected no items/abilities/weather/terrain). All three follow the selected
game — its generation's type chart, era-accurate typings (pre-Gen-6 game — its generation's type chart, era-accurate typings (pre-Gen-6
Clefairy is Normal, etc.), and era move data. Toggleable in the nav Clefairy is Normal, etc.), and era move data. Toggleable in the nav
from Settings. from Settings. Links out to a Natures table and an interactive Type
chart (tap a type for its offensive + defensive breakdown; both
gen-aware).
- **Settings** — theme (System / Light / Dark / Black / Sepia) + accent - **Settings** — theme (System / Light / Dark / Black / Sepia) + accent
colour, text size, an in-app reduce-motion override (on top of the OS colour, text size, an in-app reduce-motion override (on top of the OS
preference, which is always respected too), sprite style, haptic preference, which is always respected too), sprite style, haptic

View File

@ -8,6 +8,7 @@ import { ItemDetail } from './views/ItemDetail.js';
import { AbilityDetail } from './views/AbilityDetail.js'; import { AbilityDetail } from './views/AbilityDetail.js';
import { TeamView } from './views/TeamView.js'; import { TeamView } from './views/TeamView.js';
import { NaturesView } from './views/NaturesView.js'; import { NaturesView } from './views/NaturesView.js';
import { TypeChartView } from './views/TypeChartView.js';
import { detailSkeleton, lookupSkeleton } from './components/skeletons.js'; import { detailSkeleton, lookupSkeleton } from './components/skeletons.js';
import { prefersReducedMotion } from './store/settings.js'; import { prefersReducedMotion } from './store/settings.js';
@ -19,6 +20,7 @@ const routes = [
{ pattern: /^#\/ability\/(\d+)$/, view: (m) => AbilityDetail(Number(m[1])), skeleton: lookupSkeleton }, { pattern: /^#\/ability\/(\d+)$/, view: (m) => AbilityDetail(Number(m[1])), skeleton: lookupSkeleton },
{ pattern: /^#\/team$/, view: () => TeamView() }, { pattern: /^#\/team$/, view: () => TeamView() },
{ pattern: /^#\/natures$/, view: () => NaturesView() }, { pattern: /^#\/natures$/, view: () => NaturesView() },
{ pattern: /^#\/types$/, view: () => TypeChartView() },
{ pattern: /^#\/search$/, view: () => SearchView() }, { pattern: /^#\/search$/, view: () => SearchView() },
{ pattern: /^#\/settings$/, view: () => SettingsView() }, { pattern: /^#\/settings$/, view: () => SettingsView() },
]; ];

View File

@ -2909,3 +2909,101 @@
font-weight: 700; font-weight: 700;
color: color-mix(in srgb, var(--type-main) 55%, var(--text-dim)); color: color-mix(in srgb, var(--type-main) 55%, var(--text-dim));
} }
/* ---- Type chart page ---------------------------------------- */
.tchart__scroll {
overflow-x: auto;
scrollbar-width: thin;
-webkit-overflow-scrolling: touch;
}
.tchart {
display: grid;
grid-template-columns: 2.6rem repeat(var(--n), 1.9rem);
gap: 2px;
min-width: max-content;
padding-bottom: 4px;
}
.tchart__corner {
font-size: 0.5rem;
line-height: 1.1;
white-space: pre;
color: var(--text-dim);
display: grid;
place-items: center;
text-align: center;
}
.tchart__col,
.tchart__row {
font-family: inherit;
font-size: 0.56rem;
font-weight: 800;
letter-spacing: 0.02em;
border: none;
cursor: pointer;
padding: 0;
min-height: 1.9rem;
border-radius: 4px;
color: var(--text);
background: color-mix(in srgb, var(--tc) 26%, var(--surface-2));
}
.tchart__col.is-sel,
.tchart__row.is-sel {
background: var(--tc);
color: #fff;
}
.tchart__cell {
display: grid;
place-items: center;
min-height: 1.9rem;
font-size: 0.66rem;
font-weight: 700;
border-radius: 4px;
background: var(--surface-2);
font-variant-numeric: tabular-nums;
}
.tchart__cell.m2 {
background: color-mix(in srgb, var(--danger) 42%, transparent);
color: #fff;
}
.tchart__cell.m05 {
background: color-mix(in srgb, var(--good) 32%, transparent);
}
.tchart__cell.m0 {
background: color-mix(in srgb, var(--text-dim) 35%, var(--surface-2));
color: var(--text-dim);
}
.tchart__cell.is-hot {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.tchart__legend {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
font-size: 0.75rem;
color: var(--text-dim);
margin: 12px 0;
}
.tchart__legend .tchart__cell {
width: 1.5rem;
min-height: 1.5rem;
flex: none;
}
.tchart__summary {
margin-top: 16px;
}
.tchart__srow {
display: flex;
gap: 8px;
align-items: baseline;
flex-wrap: wrap;
margin-bottom: 8px;
}
.tchart__slabel {
font-size: 0.76rem;
font-weight: 700;
color: var(--text-dim);
flex: none;
min-width: 9rem;
}

View File

@ -72,7 +72,9 @@ export async function TeamView() {
'p', 'p',
{}, {},
`Up to ${MAX_TEAM} Pokémon — check weaknesses, coverage and stats. `, `Up to ${MAX_TEAM} Pokémon — check weaknesses, coverage and stats. `,
el('a', { class: 'link', href: '#/natures' }, 'Natures reference'), el('a', { class: 'link', href: '#/natures' }, 'Natures'),
' · ',
el('a', { class: 'link', href: '#/types' }, 'Type chart'),
), ),
), ),
seg, seg,

146
src/views/TypeChartView.js Normal file
View File

@ -0,0 +1,146 @@
import { el, clear } from '../lib/dom.js';
import { settings } from '../store/settings.js';
import { loadSnapshot } from '../data/snapshot.js';
import { prettify } from '../data/pokedex-resolver.js';
import { TYPES, multiplier, offensiveSummary, defensiveMatchups } from '../data/type-chart.js';
import { TypeChip } from '../components/TypeChip.js';
import { typeHex } from '../lib/type-color.js';
const ABBR = {
normal: 'NOR', fire: 'FIR', water: 'WAT', electric: 'ELE', grass: 'GRA', ice: 'ICE',
fighting: 'FIG', poison: 'POI', ground: 'GRD', flying: 'FLY', psychic: 'PSY', bug: 'BUG',
rock: 'ROC', ghost: 'GHO', dragon: 'DRA', dark: 'DRK', steel: 'STE', fairy: 'FAI',
};
const cellText = (m) => (m === 0 ? '0' : m === 0.5 ? '½' : m === 2 ? '2' : '');
const cellClass = (m) => (m === 0 ? 'm0' : m === 0.5 ? 'm05' : m === 2 ? 'm2' : 'm1');
export async function TypeChartView() {
const view = el('section', { class: 'view lookup tchartview' });
const snap = await loadSnapshot();
const st = settings.get();
const vg = snap.versionGroupByKey.get(st.versionGroup);
const gen = vg ? vg.generation : 9;
// Fairy is Gen 6+, Steel/Dark are Gen 2+.
const types = TYPES.filter((t) => {
if (t === 'fairy' && gen < 6) return false;
if ((t === 'steel' || t === 'dark') && gen < 2) return false;
return true;
});
let selected = null; // a type name, or null
const grid = el('div', { class: 'tchart' });
const summary = el('div', { class: 'tchart__summary' });
function select(t) {
selected = selected === t ? null : t;
paint();
}
function paint() {
grid.style.setProperty('--n', String(types.length));
clear(grid);
// corner
grid.append(el('span', { class: 'tchart__corner' }, 'ATK→\nDEF↓'));
// column headers (defending)
for (const def of types) {
grid.append(
el(
'button',
{
type: 'button',
class: `tchart__col${selected === def ? ' is-sel' : ''}`,
style: `--tc:${typeHex(def)}`,
title: prettify(def),
onclick: () => select(def),
},
ABBR[def],
),
);
}
// rows (attacking)
for (const atk of types) {
grid.append(
el(
'button',
{
type: 'button',
class: `tchart__row${selected === atk ? ' is-sel' : ''}`,
style: `--tc:${typeHex(atk)}`,
title: prettify(atk),
onclick: () => select(atk),
},
ABBR[atk],
),
);
for (const def of types) {
const m = multiplier(atk, [def], gen);
const hot = selected === atk || selected === def;
grid.append(
el(
'span',
{ class: `tchart__cell ${cellClass(m)}${hot ? ' is-hot' : ''}` },
cellText(m),
),
);
}
}
clear(summary);
if (!selected) {
summary.append(
el('p', { class: 'detail__muted' }, 'Tap a type on the edge to see everything it hits and everything that hits it.'),
);
return;
}
const inEra = (list) => (list || []).filter((t) => types.includes(t));
const off = offensiveSummary([selected], gen);
const def = defensiveMatchups([selected], gen);
const chipRow = (label, list) =>
list.length
? el('div', { class: 'tchart__srow' }, el('span', { class: 'tchart__slabel' }, label), el('span', { class: 'matchups__types' }, ...list.map(TypeChip)))
: null;
summary.append(
el('h3', { class: 'ppanel__sub' }, `${prettify(selected)} — attacking`),
chipRow('Super effective vs', inEra(off.strong)),
chipRow('Not very effective / no effect vs', inEra(off.walls)),
el('h3', { class: 'ppanel__sub' }, `${prettify(selected)} — defending`),
chipRow('Weak to', inEra(def['2'])),
chipRow('Resists', inEra(def['0.5'])),
chipRow('Immune to', inEra(def['0'])),
);
}
paint();
clear(view).append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/team' }, ' Team')),
el(
'header',
{ class: 'view__header' },
el('h1', {}, 'Type chart'),
el(
'p',
{},
st.versionGroup === 'all'
? 'Modern rules (Gen 6+).'
: `${prettify(st.versionGroup)} — Gen ${gen} rules${
gen < 6 ? ', no Fairy' : ''
}${gen < 2 ? ', no Steel/Dark' : ''}.`,
),
),
el(
'div',
{ class: 'tchart__legend' },
el('span', { class: 'tchart__cell m2' }, '2'),
' super effective ',
el('span', { class: 'tchart__cell m05' }, '½'),
' resisted ',
el('span', { class: 'tchart__cell m0' }, '0'),
' no effect',
),
el('div', { class: 'tchart__scroll' }, grid),
summary,
);
return view;
}