dex/src/views/BreedingView.js
chris a437f539fc Animation pass: shared-element morph, sliding tabs, count-ups
New lib/anim.js (countUp, fadeSlideIn, segThumb), all reduced-motion
aware:

- Shared-element morph: tapping a card tags its art with
  view-transition-name; the detail skeleton now renders the real sprite
  at hero size (router passes the match, animates the skeleton mount), so
  the sprite flies from the grid into the hero and the skeleton→content
  swap is seamless.
- Segmented controls (Team / Search / Breeding) get a sliding pill that
  measures each button and eases between them.
- Count-ups: the dex-feed progress number and the detail Stats total
  tick up.
- Detail tab content fades/rises on switch.
- Global :active scale-press on buttons/chips/rows, plus soft
  background/border transitions.
- Hero sprite has a slow idle bob.
- Router swallows the AbortError when a fast follow-up nav skips an
  in-flight view transition.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-10 09:22:08 -04:00

236 lines
7.4 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 { settings } from '../store/settings.js';
import { ui } from '../store/ui.js';
import { getPokemon } from '../data/api.js';
import { prettify } from '../data/pokedex-resolver.js';
import { Sprite } from '../components/Sprite.js';
import { openPokemonPicker } from '../components/PokemonPicker.js';
import { segThumb } from '../lib/anim.js';
const EGG_LABEL = {
water1: 'Water 1', water2: 'Water 2', water3: 'Water 3',
ground: 'Field', humanshape: 'Human-Like', indeterminate: 'Amorphous',
plant: 'Grass', 'no-eggs': 'Undiscovered', ditto: 'Ditto',
};
const eggLabel = (g) => EGG_LABEL[g] || prettify(g);
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
export async function BreedingView() {
const view = el('section', { class: 'view lookup breedview' });
const snap = await loadSnapshot();
const style = settings.get().spriteStyle;
const vg = settings.get().versionGroup;
const groups = [...new Set(snap.species.flatMap((s) => s.eggGroups || []))]
.filter((g) => g !== 'no-eggs')
.sort((a, b) => eggLabel(a).localeCompare(eggLabel(b)));
let mode = ui.get().breedMode === 'compat' ? 'compat' : 'groups';
let group = groups.includes(ui.get().breedGroup) ? ui.get().breedGroup : groups[0];
let target = snap.speciesById.get(ui.get().breedTarget) || null;
const SEG = [
['groups', 'Egg groups'],
['compat', 'Can breed with'],
];
const seg = el(
'div',
{ class: 'seg' },
...SEG.map(([id, label], i) =>
el(
'button',
{
type: 'button',
class: `seg__btn${id === mode ? ' is-active' : ''}`,
onclick: () => {
mode = id;
ui.set({ breedMode: id });
seg
.querySelectorAll('.seg__btn')
.forEach((b, j) => b.classList.toggle('is-active', SEG[j][0] === id));
setThumb(i);
render();
},
},
label,
),
),
);
const setThumb = segThumb(seg, SEG.length, Math.max(0, SEG.findIndex(([id]) => id === mode)));
const body = el('div', { class: 'breedview__body' });
function monGrid(list) {
return list.length
? el(
'div',
{ class: 'ab-mons' },
...list.map((sp) =>
el(
'a',
{ class: 'ab-mon', href: `#/pokemon/${sp.id}` },
Sprite(sp.id, { style, alt: sp.name, size: 48 }),
el('span', {}, sp.name.replace(/-/g, ' ')),
),
),
)
: el('p', { class: 'detail__muted' }, 'None.');
}
function renderGroups() {
const chips = el(
'div',
{ class: 'breed__chips' },
...groups.map((g) =>
el(
'button',
{
type: 'button',
class: `breed__chip${g === group ? ' is-on' : ''}`,
onclick: () => {
group = g;
ui.set({ breedGroup: g });
render();
},
},
eggLabel(g),
),
),
);
const list = snap.species
.filter((s) => (s.eggGroups || []).includes(group))
.sort((a, b) => a.id - b.id);
body.append(
chips,
el('h2', { class: 'ppanel__sub' }, `${eggLabel(group)} (${list.length})`),
monGrid(list),
);
}
function renderCompat() {
const slot = el(
'button',
{
type: 'button',
class: 'breed__pick',
onclick: () =>
openPokemonPicker(
(id) => {
target = snap.speciesById.get(id);
ui.set({ breedTarget: id });
render();
},
{ closeAfterPick: true },
),
},
target
? el('span', {}, Sprite(target.id, { style, size: 40, alt: target.name }), ` ${prettify(target.name)}`)
: 'Choose a Pokémon…',
);
body.append(slot);
if (!target) return;
const tGroups = new Set(target.eggGroups || []);
if (tGroups.has('no-eggs')) {
body.append(el('p', { class: 'detail__muted' }, `${prettify(target.name)} is in the Undiscovered egg group — it cant be bred.`));
return;
}
const isDitto = tGroups.has('ditto');
const genderless = target.genderRate === -1;
let list;
if (isDitto) {
list = snap.species.filter((s) => !s.eggGroups.includes('no-eggs') && !s.eggGroups.includes('ditto'));
} else if (genderless) {
list = snap.species.filter((s) => (s.eggGroups || []).includes('ditto'));
} else {
list = snap.species.filter((s) => {
if (s.id === target.id) return false;
if ((s.eggGroups || []).includes('ditto')) return true;
if ((s.eggGroups || []).includes('no-eggs')) return false;
return (s.eggGroups || []).some((g) => tGroups.has(g) && g !== 'ditto');
});
}
list.sort((a, b) => a.id - b.id);
const note = genderless
? 'Genderless — can only breed with Ditto.'
: isDitto
? 'Ditto breeds with any Pokémon that can breed.'
: `Egg groups: ${[...tGroups].map(eggLabel).join(', ')}. Needs an opposite-gender partner (or Ditto).`;
// Egg moves belong to the family's lowest breeding stage (Pichu, not
// Pikachu) — walk down the evolves-from chain.
let base = target;
while (base.evolvesFromId) {
const prev = snap.speciesById.get(base.evolvesFromId);
if (!prev) break;
base = prev;
}
const eggMovesBox = el('div', { class: 'breed__eggmoves' }, el('p', { class: 'detail__muted' }, 'Loading egg moves…'));
getPokemon(base.id)
.then((pk) => {
const eggMoves = pk.moves.filter((m) =>
m.version_group_details.some(
(d) => d.move_learn_method.name === 'egg' && (vg === 'all' || d.version_group.name === vg),
),
);
clear(eggMovesBox);
if (!eggMoves.length) {
eggMovesBox.append(el('p', { class: 'detail__muted' }, 'No egg moves for this game.'));
return;
}
eggMovesBox.append(
el(
'h3',
{ class: 'ppanel__sub' },
`Egg moves (${eggMoves.length})${base.id !== target.id ? ` — hatches as ${prettify(base.name)}` : ''}`,
),
el(
'p',
{ class: 'detail__muted' },
'A parent that knows one of these passes it down. Open a move to see which Pokémon can learn it.',
),
el(
'div',
{ class: 'breed__eggmove-list' },
...eggMoves.map((m) =>
el('a', { class: 'link', href: `#/move/${idFromUrl(m.move.url)}` }, m.move.name.replace(/-/g, ' ')),
),
),
);
})
.catch(() => clear(eggMovesBox).append(el('p', { class: 'detail__muted' }, 'Egg moves unavailable offline.')));
body.append(
el('p', { class: 'breed__note' }, note),
eggMovesBox,
el('h2', { class: 'ppanel__sub' }, `Compatible partners (${list.length})`),
monGrid(list),
);
}
function render() {
clear(body);
if (mode === 'groups') renderGroups();
else renderCompat();
}
clear(view).append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/team' }, ' Team')),
el(
'header',
{ class: 'view__header' },
el('h1', {}, 'Breeding'),
el('p', {}, 'Egg-group members and who a Pokémon can produce eggs with.'),
),
seg,
body,
);
render();
onTeardown(view, () => {});
return view;
}