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 '';
}
function node(stage, style, versionGroup) {
function nodeEl(stage, style, versionGroup, depth) {
return el(
'a',
{ class: 'evo__node', href: `#/pokemon/${stage.id}` },
Sprite(stage.id, { style, versionGroup, alt: stage.name, size: 72 }),
{ class: 'evo__node', href: `#/pokemon/${stage.id}`, style: `--d:${depth}` },
Sprite(stage.id, { style, versionGroup, alt: stage.name, size: 56 }),
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 rows = [];
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('div', { class: 'evo' });
const wrap = el(
'div',
{ class: 'evo' },
...rows.map((path) => {
const row = el('div', { class: 'evo__row' });
path.forEach((stage, i) => {
if (i > 0) {
row.append(
el(
'span',
{ class: 'evo__arrow' },
'→',
stage.via ? el('span', { class: 'evo__via' }, stage.via) : null,
),
);
}
row.append(node(stage, style, versionGroup));
});
return row;
}),
);
// Vertical tree: a stage, then for each branch a "↓ trigger" step and the
// stage it leads to. Depth adds indentation so mid-chain splits (Wurmple)
// stay grouped without overflowing on a phone.
const renderNode = (n, depth) => {
wrap.append(nodeEl(n, style, versionGroup, depth));
for (const kidId of n.kids) {
const kid = nodes.get(kidId);
wrap.append(
el(
'div',
{ class: 'evo__step', style: `--d:${depth + 1}` },
el('span', { class: 'evo__arrow', 'aria-hidden': 'true' }, '↓'),
el('span', { class: 'evo__via' }, kid.via || 'evolves'),
),
);
renderNode(kid, depth + 1);
}
};
roots.forEach((r) => renderNode(r, 0));
if (pruned) {
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 ----------------------------------- */
/* Vertical evolution tree — never overflows a phone; indents by depth. */
.evo {
display: flex;
flex-direction: column;
gap: 14px;
}
.evo__row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
gap: 3px;
align-items: flex-start;
}
.evo__node {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
text-decoration: none;
color: inherit;
gap: 10px;
padding: 6px 8px;
border-radius: var(--radius-sm);
text-decoration: none;
color: inherit;
margin-left: calc(var(--d, 0) * 18px);
}
.evo__node:hover {
background: var(--surface-2);
}
.evo__node .sprite {
width: 72px;
height: 72px;
width: 54px;
height: 54px;
flex: none;
object-fit: contain;
image-rendering: pixelated;
}
.evo__name {
font-size: 0.8rem;
font-weight: 600;
font-size: 0.92rem;
font-weight: 700;
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 {
display: flex;
flex-direction: column;
align-items: center;
color: var(--text-dim);
font-size: 0.95rem;
font-size: 1rem;
line-height: 1;
}
.evo__via {
font-size: 0.66rem;
text-transform: capitalize;
white-space: nowrap;
}
/* ---------- Type matchups ---------------------------------- */

View File

@ -1,4 +1,5 @@
import { el, clear, onTeardown } from '../lib/dom.js';
import { onSwipe } from '../lib/swipe.js';
import { loadSnapshot } from '../data/snapshot.js';
import { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js';
import { getPokemon, getSpecies, getEvolutionChain, getEncounters } from '../data/api.js';
@ -337,8 +338,17 @@ export async function PokemonDetail(nationalId) {
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());
onTeardown(view, off);
onTeardown(view, () => {
off();
offSwipe();
});
return view;
}