- 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>
317 lines
9.7 KiB
JavaScript
317 lines
9.7 KiB
JavaScript
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 (A–Z)', 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;
|
||
}
|