dex/src/views/DexGrid.js
chris 491d1102cc Center card sprite; ghost number to top:2px; scroll-position restore
- Card sprite sits centred in the upper area (flex:1 + place-items:center)
  instead of a forced margin; sprite-style / boxed sizing moved onto the
  img so it stays centred
- .card__ghost number nudged to top: 2px
- Feed Back-navigation restores the raw scroll offset again (dropped the
  card-anchor approach); keeps manual scrollRestoration + retry-on-reflow

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 08:25:23 -04:00

317 lines
9.7 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 { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js';
import { settings } from '../store/settings.js';
import { ui } from '../store/ui.js';
import { selection, stats } from '../store/selection.js';
import { Card } from '../components/Card.js';
import { ProgressRing } from '../components/ProgressRing.js';
const FILTERS = [
{ key: 'all', label: 'All', test: () => true },
{ key: 'caught', label: 'Caught', test: (e) => e.caught },
{ key: 'missing', label: 'Missing', test: (e) => !e.caught },
{ key: 'favorite', label: 'Favorites', test: (e) => e.favorite },
{ key: 'legendary', label: 'Legendary', test: (e, sp) => sp.isLegendary || sp.isMythical },
];
const SORTS = [
{ key: 'dex', label: 'Dex number', desc: false },
{ key: 'name', label: 'Name (AZ)', desc: false },
{ key: 'bst', label: 'Base stat total', desc: true },
{ key: 'hp', label: 'HP', desc: true },
{ key: 'atk', label: 'Attack', desc: true },
{ key: 'def', label: 'Defense', desc: true },
{ key: 'spa', label: 'Sp. Attack', desc: true },
{ key: 'spd', label: 'Sp. Defense', desc: true },
{ key: 'spe', label: 'Speed', desc: true },
{ key: 'height', label: 'Height', desc: true },
{ key: 'weight', label: 'Weight', desc: true },
{ key: 'baseExp', label: 'Base EXP', desc: true },
{ key: 'catchrate', label: 'Catch rate', desc: true },
{ key: 'caught', label: 'Recently caught', desc: true },
];
const STAT_SHORT = { hp: 'HP', atk: 'Atk', def: 'Def', spa: 'SpA', spd: 'SpD', spe: 'Spe' };
const SEARCH_ICON =
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>';
function sortValue(row, key) {
const sp = row.species;
switch (key) {
case 'dex': return row.number ?? sp.id;
case 'name': return sp.name;
case 'bst': return sp.bst ?? 0;
case 'hp': case 'atk': case 'def': case 'spa': case 'spd': case 'spe':
return sp.stats?.[key] ?? 0;
case 'height': return sp.height ?? 0;
case 'weight': return sp.weight ?? 0;
case 'baseExp': return sp.baseExp ?? 0;
case 'catchrate': return sp.captureRate ?? 0;
case 'caught': return selection.get().pokemon[sp.id]?.updatedAt || '';
default: return 0;
}
}
function metricLabel(key, sp) {
switch (key) {
case 'bst': return `BST ${sp.bst}`;
case 'hp': case 'atk': case 'def': case 'spa': case 'spd': case 'spe':
return `${STAT_SHORT[key]} ${sp.stats?.[key] ?? 0}`;
case 'height': return `${((sp.height ?? 0) / 10).toFixed(1)} m`;
case 'weight': return `${((sp.weight ?? 0) / 10).toFixed(1)} kg`;
case 'baseExp': return sp.baseExp != null ? `${sp.baseExp} EXP` : null;
case 'catchrate': return `Catch ${sp.captureRate ?? '?'}`;
default: return null;
}
}
export async function DexGrid() {
const view = el('section', { class: 'view dexgrid' });
const snap = await loadSnapshot();
const grid = el('div', { class: 'grid' });
let query = '';
let filterKey = ui.get().filter || 'all';
let sortKey = ui.get().sort || 'dex';
let sortDesc = !!ui.get().sortDesc;
let rows = [];
let ids = [];
const ring = ProgressRing();
const title = el('h1', {});
// Which game is active — a quiet label, not a control. Change it from the
// Games button in the nav.
const gameLabel = el('span', { class: 'feed-head__game' });
const search = el('input', {
class: 'field-search__input',
type: 'search',
placeholder: 'Filter this Pokédex…',
oninput: (e) => {
query = e.target.value.trim().toLowerCase();
paintGrid();
},
});
const filterBar = el('div', { class: 'feed-chips' });
const chipEls = FILTERS.map((f) => {
const count = el('span', { class: 'feed-chip__count' });
const btn = el(
'button',
{
class: `feed-chip${f.key === filterKey ? ' is-active' : ''}`,
type: 'button',
onclick: () => {
filterKey = f.key;
ui.set({ filter: f.key });
chipEls.forEach((c, i) => c.btn.classList.toggle('is-active', FILTERS[i].key === f.key));
paintGrid();
resetScroll();
},
},
el('span', {}, f.label),
count,
);
filterBar.append(btn);
return { btn, count };
});
const sortSelect = el(
'select',
{
class: 'feed-sort__select',
onchange: (e) => {
sortKey = e.target.value;
sortDesc = SORTS.find((s) => s.key === sortKey).desc;
ui.set({ sort: sortKey, sortDesc });
syncDirBtn();
paintGrid();
resetScroll();
},
},
...SORTS.map((s) => el('option', { value: s.key, selected: s.key === sortKey }, s.label)),
);
const dirBtn = el('button', {
class: 'feed-sort__dir',
type: 'button',
title: 'Reverse order',
onclick: () => {
sortDesc = !sortDesc;
ui.set({ sortDesc });
syncDirBtn();
paintGrid();
resetScroll();
},
});
function syncDirBtn() {
dirBtn.textContent = sortDesc ? '↓' : '↑';
dirBtn.setAttribute('aria-label', sortDesc ? 'Descending' : 'Ascending');
}
syncDirBtn();
// Changing the list order/contents makes the old scroll offset meaningless.
function resetScroll() {
ui.set({ feedScroll: 0 });
window.scrollTo(0, 0);
}
// Return to the saved scroll offset. Retry a few times — the grid's height
// isn't final until the webfont loads and off-screen cards settle — but
// stop once we've reached the target or the user starts scrolling.
function restoreScroll() {
const y = ui.get().feedScroll || 0;
if (!y) return;
let applied = null;
const go = () => {
if (applied != null && Math.abs(window.scrollY - applied) > 8) return; // user moved
window.scrollTo(0, y);
applied = Math.round(window.scrollY);
};
go();
setTimeout(go, 80);
setTimeout(go, 250);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(go);
}
view.append(
el(
'header',
{ class: 'feed-head' },
el(
'div',
{ class: 'feed-head__top' },
el('div', { class: 'feed-head__id' }, title, gameLabel),
ring.node,
),
el(
'div',
{ class: 'field-search' },
el('span', { class: 'field-search__icon', html: SEARCH_ICON, 'aria-hidden': 'true' }),
search,
),
filterBar,
el(
'div',
{ class: 'feed-sort' },
el('span', { class: 'feed-sort__label' }, 'Sort'),
sortSelect,
dirBtn,
),
),
grid,
);
function counts() {
const p = selection.get().pokemon;
let caught = 0;
let favorite = 0;
let legendary = 0;
for (const { species } of rows) {
const e = p[species.id];
if (e?.caught) caught++;
if (e?.favorite) favorite++;
if (species.isLegendary || species.isMythical) legendary++;
}
return { all: rows.length, caught, missing: rows.length - caught, favorite, legendary };
}
function refreshMeta() {
ring.update(stats(ids));
const c = counts();
chipEls.forEach(({ count }, i) => {
count.textContent = c[FILTERS[i].key];
});
}
function paintGrid() {
const st = settings.get();
const pokemonState = selection.get().pokemon;
const boxed =
st.spriteStyle === 'game' &&
(snap.versionGroupByKey.get(st.versionGroup)?.generation ?? 9) <= 2;
const filterTest = FILTERS.find((f) => f.key === filterKey).test;
let list = rows.filter(({ species, number }) => {
if (
query &&
!species.name.includes(query) &&
String(number) !== query &&
String(species.id) !== query
) {
return false;
}
const e = pokemonState[species.id] || { seen: false, caught: false, favorite: false };
return filterTest(e, species);
});
const dir = sortDesc ? -1 : 1;
list = list
.map((row) => ({ row, v: sortValue(row, sortKey) }))
.sort((a, b) => {
if (a.v < b.v) return -dir;
if (a.v > b.v) return dir;
return (a.row.number ?? a.row.species.id) - (b.row.number ?? b.row.species.id);
})
.map((x) => x.row);
const frag = document.createDocumentFragment();
list.forEach(({ species, number }, i) => {
const card = Card(species, number, {
spriteStyle: st.spriteStyle,
versionGroup: st.versionGroup,
boxed,
metric: metricLabel(sortKey, species),
});
if (i < 30) card.style.setProperty('--i', String(i));
frag.append(card);
});
clear(grid);
grid.append(
list.length === 0
? el('p', { class: 'grid__empty' }, 'Nothing matches those filters.')
: frag,
);
}
function rebuild() {
const st = settings.get();
const dex = resolvePokedex(snap, st);
rows = dexRows(snap, dex);
ids = rows.map((r) => r.species.id);
title.textContent = prettify(dex.name || dex.key);
gameLabel.textContent =
st.versionGroup === 'all' ? 'All games' : prettify(st.versionGroup);
refreshMeta();
paintGrid();
}
let mountedVG = settings.get().versionGroup;
rebuild();
// Return to where the user last scrolled (e.g. after viewing a Pokémon).
requestAnimationFrame(restoreScroll);
const offSettings = settings.subscribe((s) => {
if (s.versionGroup !== mountedVG) {
mountedVG = s.versionGroup;
ui.set({ feedScroll: 0 });
}
rebuild();
});
const offSelection = selection.subscribe(() => {
refreshMeta();
if (filterKey !== 'all' && filterKey !== 'legendary') paintGrid();
else if (sortKey === 'caught') paintGrid();
});
onTeardown(view, () => {
ui.set({ feedScroll: window.scrollY });
offSettings();
offSelection();
});
return view;
}