From 8ebdb9d19702cfe4dae73e40dddd2017b9af9ae1 Mon Sep 17 00:00:00 2001 From: chris Date: Fri, 28 Aug 2026 08:49:15 -0400 Subject: [PATCH] Vertical evolution tree; swipe to page through the dex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/EvolutionChain.js | 54 ++++++++++++---------------- src/lib/swipe.js | 62 ++++++++++++++++++++++++++++++++ src/styles/layout.css | 44 +++++++++++------------ src/views/PokemonDetail.js | 12 ++++++- 4 files changed, 118 insertions(+), 54 deletions(-) create mode 100644 src/lib/swipe.js diff --git a/src/components/EvolutionChain.js b/src/components/EvolutionChain.js index 3c0910a..f3b519f 100644 --- a/src/components/EvolutionChain.js +++ b/src/components/EvolutionChain.js @@ -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( diff --git a/src/lib/swipe.js b/src/lib/swipe.js new file mode 100644 index 0000000..dd5fa86 --- /dev/null +++ b/src/lib/swipe.js @@ -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); + }; +} diff --git a/src/styles/layout.css b/src/styles/layout.css index ad750d8..6d0cb49 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -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 ---------------------------------- */ diff --git a/src/views/PokemonDetail.js b/src/views/PokemonDetail.js index 5e25c92..0c34735 100644 --- a/src/views/PokemonDetail.js +++ b/src/views/PokemonDetail.js @@ -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; }