Vertical evolution tree; swipe to page through the dex

- Evolution chain is now a vertical tree (stage, then '↓ trigger' step,
  then the next stage) that indents by branch depth. Never overflows a
  phone; branching chains (Eevee, Wurmple) stay grouped and readable.
- Swipe left/right on the Pokémon detail page navigates prev/next in the
  current dex (src/lib/swipe.js). Claims horizontal drags via
  preventDefault so page scroll and the browser's edge back-gesture don't
  steal them; vertical drags pass through.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
chris 2026-08-28 08:49:15 -04:00
parent 1d35f2d433
commit 8ebdb9d197
4 changed files with 118 additions and 54 deletions

View File

@ -18,11 +18,11 @@ function describe(details) {
return ''; return '';
} }
function node(stage, style, versionGroup) { function nodeEl(stage, style, versionGroup, depth) {
return el( return el(
'a', 'a',
{ class: 'evo__node', href: `#/pokemon/${stage.id}` }, { class: 'evo__node', href: `#/pokemon/${stage.id}`, style: `--d:${depth}` },
Sprite(stage.id, { style, versionGroup, alt: stage.name, size: 72 }), Sprite(stage.id, { style, versionGroup, alt: stage.name, size: 56 }),
el('span', { class: 'evo__name' }, stage.name.replace(/-/g, ' ')), el('span', { class: 'evo__name' }, stage.name.replace(/-/g, ' ')),
); );
} }
@ -72,35 +72,27 @@ export function EvolutionChain(chain, { style = 'default', versionGroup, genOf =
} }
const roots = [...nodes.values()].filter((n) => n.parent == null || !nodes.has(n.parent)); const roots = [...nodes.values()].filter((n) => n.parent == null || !nodes.has(n.parent));
const rows = []; const wrap = el('div', { class: 'evo' });
const walkPaths = (n, acc) => {
const path = [...acc, n];
if (!n.kids.length) rows.push(path);
else for (const k of n.kids) walkPaths(nodes.get(k), path);
};
roots.forEach((r) => walkPaths(r, []));
const wrap = el( // Vertical tree: a stage, then for each branch a "↓ trigger" step and the
'div', // stage it leads to. Depth adds indentation so mid-chain splits (Wurmple)
{ class: 'evo' }, // stay grouped without overflowing on a phone.
...rows.map((path) => { const renderNode = (n, depth) => {
const row = el('div', { class: 'evo__row' }); wrap.append(nodeEl(n, style, versionGroup, depth));
path.forEach((stage, i) => { for (const kidId of n.kids) {
if (i > 0) { const kid = nodes.get(kidId);
row.append( wrap.append(
el( el(
'span', 'div',
{ class: 'evo__arrow' }, { class: 'evo__step', style: `--d:${depth + 1}` },
'→', el('span', { class: 'evo__arrow', 'aria-hidden': 'true' }, '↓'),
stage.via ? el('span', { class: 'evo__via' }, stage.via) : null, el('span', { class: 'evo__via' }, kid.via || 'evolves'),
), ),
); );
renderNode(kid, depth + 1);
} }
row.append(node(stage, style, versionGroup)); };
}); roots.forEach((r) => renderNode(r, 0));
return row;
}),
);
if (pruned) { if (pruned) {
wrap.append( wrap.append(

62
src/lib/swipe.js Normal file
View File

@ -0,0 +1,62 @@
/**
* Detect a deliberate horizontal swipe on a touch device.
*
* Once the drag reads as horizontal it calls `preventDefault()` on
* touchmove, which both stops the page scrolling sideways and stops the
* browser's own edge back/forward gesture from stealing the swipe. Vertical
* drags (scrolling), multi-touch and slow drags are ignored.
*
* onSwipe(node, { onLeft, onRight }) -> cleanup fn
*/
export function onSwipe(target, { onLeft, onRight, threshold = 60 } = {}) {
let x0 = 0;
let y0 = 0;
let t0 = 0;
let active = false;
let claimed = false;
const start = (e) => {
if (e.touches.length !== 1) {
active = false;
return;
}
x0 = e.touches[0].clientX;
y0 = e.touches[0].clientY;
t0 = Date.now();
active = true;
claimed = false;
};
const move = (e) => {
if (!active || e.touches.length !== 1) return;
const dx = e.touches[0].clientX - x0;
const dy = e.touches[0].clientY - y0;
if (!claimed) {
if (Math.abs(dy) > 12 && Math.abs(dy) > Math.abs(dx)) {
active = false; // it's a vertical scroll — let it be
return;
}
if (Math.abs(dx) > 10 && Math.abs(dx) > Math.abs(dy)) claimed = true;
}
if (claimed) e.preventDefault();
};
const end = (e) => {
if (!active) return;
active = false;
if (!claimed) return;
const t = e.changedTouches[0];
const dx = t.clientX - x0;
if (Date.now() - t0 > 700 || Math.abs(dx) < threshold) return;
(dx < 0 ? onLeft : onRight)?.();
};
target.addEventListener('touchstart', start, { passive: true });
target.addEventListener('touchmove', move, { passive: false });
target.addEventListener('touchend', end, { passive: true });
return () => {
target.removeEventListener('touchstart', start);
target.removeEventListener('touchmove', move);
target.removeEventListener('touchend', end);
};
}

View File

@ -1124,52 +1124,52 @@
} }
/* ---------- Evolution chain ----------------------------------- */ /* ---------- Evolution chain ----------------------------------- */
/* Vertical evolution tree — never overflows a phone; indents by depth. */
.evo { .evo {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 3px;
} align-items: flex-start;
.evo__row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
} }
.evo__node { .evo__node {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 2px; gap: 10px;
text-decoration: none;
color: inherit;
padding: 6px 8px; padding: 6px 8px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
text-decoration: none;
color: inherit;
margin-left: calc(var(--d, 0) * 18px);
} }
.evo__node:hover { .evo__node:hover {
background: var(--surface-2); background: var(--surface-2);
} }
.evo__node .sprite { .evo__node .sprite {
width: 72px; width: 54px;
height: 72px; height: 54px;
flex: none;
object-fit: contain; object-fit: contain;
image-rendering: pixelated; image-rendering: pixelated;
} }
.evo__name { .evo__name {
font-size: 0.8rem; font-size: 0.92rem;
font-weight: 600; font-weight: 700;
text-transform: capitalize; text-transform: capitalize;
} }
.evo__step {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-dim);
font-size: 0.74rem;
margin-left: calc(var(--d, 0) * 18px + 16px);
}
.evo__arrow { .evo__arrow {
display: flex; font-size: 1rem;
flex-direction: column; line-height: 1;
align-items: center;
color: var(--text-dim);
font-size: 0.95rem;
} }
.evo__via { .evo__via {
font-size: 0.66rem;
text-transform: capitalize; text-transform: capitalize;
white-space: nowrap;
} }
/* ---------- Type matchups ---------------------------------- */ /* ---------- Type matchups ---------------------------------- */

View File

@ -1,4 +1,5 @@
import { el, clear, onTeardown } from '../lib/dom.js'; import { el, clear, onTeardown } from '../lib/dom.js';
import { onSwipe } from '../lib/swipe.js';
import { loadSnapshot } from '../data/snapshot.js'; import { loadSnapshot } from '../data/snapshot.js';
import { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js'; import { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js';
import { getPokemon, getSpecies, getEvolutionChain, getEncounters } from '../data/api.js'; import { getPokemon, getSpecies, getEvolutionChain, getEncounters } from '../data/api.js';
@ -337,8 +338,17 @@ export async function PokemonDetail(nationalId) {
selectTab(ui.get().detailTab); selectTab(ui.get().detailTab);
// Swipe left/right to page through the current dex.
const offSwipe = onSwipe(view, {
onLeft: () => next && (location.hash = `#/pokemon/${next.species.id}`),
onRight: () => prev && (location.hash = `#/pokemon/${prev.species.id}`),
});
const off = settings.subscribe(() => syncTrack()); const off = settings.subscribe(() => syncTrack());
onTeardown(view, off); onTeardown(view, () => {
off();
offSwipe();
});
return view; return view;
} }