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
game — its generation's type chart, era-accurate typings (pre-Gen-6
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
colour, text size, an in-app reduce-motion override (on top of the OS
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 { TeamView } from './views/TeamView.js';
import { NaturesView } from './views/NaturesView.js';
import { TypeChartView } from './views/TypeChartView.js';
import { detailSkeleton, lookupSkeleton } from './components/skeletons.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: /^#\/team$/, view: () => TeamView() },
{ pattern: /^#\/natures$/, view: () => NaturesView() },
{ pattern: /^#\/types$/, view: () => TypeChartView() },
{ pattern: /^#\/search$/, view: () => SearchView() },
{ pattern: /^#\/settings$/, view: () => SettingsView() },
];

View File

@ -2909,3 +2909,101 @@
font-weight: 700;
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',
{},
`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,

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