diff --git a/README.md b/README.md index 04b57c7..ad2e665 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/router.js b/src/router.js index 22fb943..e43f683 100644 --- a/src/router.js +++ b/src/router.js @@ -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() }, ]; diff --git a/src/styles/layout.css b/src/styles/layout.css index b8b52a9..157d356 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -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; +} diff --git a/src/views/TeamView.js b/src/views/TeamView.js index 1ce5415..3defd11 100644 --- a/src/views/TeamView.js +++ b/src/views/TeamView.js @@ -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, diff --git a/src/views/TypeChartView.js b/src/views/TypeChartView.js new file mode 100644 index 0000000..645eac2 --- /dev/null +++ b/src/views/TypeChartView.js @@ -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; +}