Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/lib/components/pokemon/counter-list.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<script lang="ts">
import type { DexListEntry } from "$lib/dex/list.js";
import { titleCase } from "$lib/pokemon/format.js";
import type { Counter } from "$lib/pokemon/insights.js";
import { formatMultiplier } from "$lib/pokemon/matchups.js";
import type { TypeName } from "$lib/pokemon/types.js";
import PokemonArt from "./pokemon-art.svelte";
import TypeBadge from "./type-badge.svelte";

let { counters, types }: { counters: Counter<DexListEntry>[]; types: TypeName[] } = $props();

const attacks = $derived(types.map(titleCase).join(" and "));
</script>

{#if counters.length}
<ul class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each counters as { entry, attack, takes } (entry.id)}
<li>
<a
href="/pokemon/{entry.slug}"
class="flex items-center gap-3 rounded-lg border bg-card p-2 pr-3 transition-colors hover:border-(--tint)"
style:--tint="var(--type-{entry.types[0]})"
data-sveltekit-preload-data="hover"
>
<span class="tint-field grid size-16 shrink-0 place-items-center rounded-md p-1">
<PokemonArt id={entry.id} name="" width={96} widths={[96, 192]} sizes="64px" />
</span>
<span class="min-w-0 flex-1">
<span class="block truncate font-medium">{entry.name}</span>
<span class="mt-1 flex items-center gap-1.5 text-sm">
<TypeBadge type={attack.type} size="sm" />
<span class="font-medium tabular">{formatMultiplier(attack.multiplier)}</span>
<span class="text-muted-foreground">against it</span>
</span>
<span class="mt-0.5 block text-sm text-muted-foreground">
{takes === 0 ? "Immune to" : "Resists"} its {attacks} attacks
</span>
</span>
</a>
</li>
{/each}
</ul>
{:else}
<p class="text-sm text-muted-foreground">
No fully evolved Pokémon both resists every one of its same-type attacks and hits it super effectively.
</p>
{/if}
45 changes: 45 additions & 0 deletions src/lib/components/pokemon/fitted-name.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<script lang="ts">
import { nameFit } from "$lib/pokemon/name-fit.js";

let { name }: { name: string } = $props();

const fit = $derived(nameFit(name));

// The CSS estimate can overshoot for unusually wide letters; shrink until it fits.
function shrinkToFit(node: HTMLElement) {
const parent = node.parentElement;
if (!parent) return;
const fitNow = () => {
node.style.fontSize = "";
const overflow = node.scrollWidth / parent.clientWidth;
if (overflow > 1) {
node.style.fontSize = `${parseFloat(getComputedStyle(node).fontSize) / overflow}px`;
}
};
const observer = new ResizeObserver(fitNow);
observer.observe(parent);
document.fonts.ready.then(fitNow);
return () => observer.disconnect();
}
</script>

<div class="@container">
{#key name}
<h1
{@attach shrinkToFit}
class="fitted leading-[0.95] font-bold tracking-tight whitespace-nowrap"
style:--stretch="{fit.stretch}%"
style:--em={fit.em}
style:--chars={fit.chars}
>
{name}
</h1>
{/key}
</div>

<style>
.fitted {
font-stretch: var(--stretch);
font-size: min(9rem, calc(100cqi / var(--chars) / var(--em) * 0.97));
}
</style>
30 changes: 12 additions & 18 deletions src/lib/components/pokemon/pokemon-about.svelte
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
<script lang="ts">
import type { PokemonDetail } from "$lib/pokemon/detail.js";
import { formatHeight, formatWeight } from "$lib/pokemon/format.js";
import FlavorText from "./flavor-text.svelte";
import GenderRatio from "./gender-ratio.svelte";
import SizeComparison from "./size-comparison.svelte";

let { detail }: { detail: PokemonDetail } = $props();

const height = $derived(formatHeight(detail.entry.height));
const weight = $derived(formatWeight(detail.entry.weight));
</script>

<div class="grid gap-8 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)] lg:gap-12">
Expand All @@ -30,18 +27,15 @@
</dl>
</div>

<dl class="grid content-start divide-y rounded-lg border bg-card text-sm">
<div class="grid grid-cols-[7rem_1fr] items-baseline gap-4 px-4 py-3">
<dt class="text-muted-foreground">Height</dt>
<dd class="tabular"><span class="font-medium">{height.metric}</span> <span class="ml-2 text-muted-foreground">{height.imperial}</span></dd>
</div>
<div class="grid grid-cols-[7rem_1fr] items-baseline gap-4 px-4 py-3">
<dt class="text-muted-foreground">Weight</dt>
<dd class="tabular"><span class="font-medium">{weight.metric}</span> <span class="ml-2 text-muted-foreground">{weight.imperial}</span></dd>
</div>
<div class="grid grid-cols-[7rem_1fr] items-center gap-4 px-4 py-3">
<dt class="text-muted-foreground">Gender</dt>
<dd><GenderRatio rate={detail.breeding.genderRate} /></dd>
</div>
</dl>
<div class="grid content-start gap-4">
{#key detail.entry.id}
<SizeComparison id={detail.entry.id} name={detail.entry.name} height={detail.entry.height} weight={detail.entry.weight} />
{/key}
<dl class="rounded-lg border bg-card text-sm">
<div class="grid grid-cols-[7rem_1fr] items-center gap-4 px-4 py-3">
<dt class="text-muted-foreground">Gender</dt>
<dd><GenderRatio rate={detail.breeding.genderRate} /></dd>
</div>
</dl>
</div>
</div>
52 changes: 31 additions & 21 deletions src/lib/components/pokemon/pokemon-hero.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,23 @@
import { Toggle } from "$lib/components/ui/toggle/index.js";
import type { PokemonDetail } from "$lib/pokemon/detail.js";
import { dexNumber } from "$lib/pokemon/format.js";
import { statRole } from "$lib/pokemon/role.js";
import CollectionButtons from "./collection-buttons.svelte";
import CryButton from "./cry-button.svelte";
import FittedName from "./fitted-name.svelte";
import PokemonArt from "./pokemon-art.svelte";
import ShapePortrait from "./shape-portrait.svelte";
import TypeBadge from "./type-badge.svelte";

let { detail }: { detail: PokemonDetail } = $props();

let shiny = $state(false);
const entry = $derived(detail.entry);
const role = $derived(statRole(entry.stats));
const roleQuery = $derived(`role:${role.label.toLowerCase().replaceAll(" ", "-")}`);
</script>

<div class="dark type-band">
<div class="dark type-band overflow-hidden">
<div class="mx-auto max-w-7xl px-4 sm:px-6">
<nav aria-label="Adjacent Pokémon" class="flex items-center justify-between gap-4 pt-4 text-sm">
{#if detail.prev}
Expand All @@ -40,12 +45,10 @@
{/if}
</nav>

<div class="grid items-center gap-6 pt-4 pb-8 md:grid-cols-[minmax(0,1fr)_20rem] lg:grid-cols-[minmax(0,1fr)_26rem] lg:pb-10">
<div class="order-2 md:order-1">
<div class="grid items-center gap-x-10 gap-y-4 pt-2 pb-8 md:grid-cols-[minmax(0,1fr)_22rem] lg:grid-cols-[minmax(0,1fr)_28rem] lg:pb-10">
<div class="order-2 min-w-0 md:order-1">
<p class="font-display text-xl font-medium text-muted-foreground">{dexNumber(entry.speciesId)}</p>
<h1 class="mt-1 font-display text-5xl leading-[0.95] font-bold tracking-tight text-balance sm:text-7xl">
{entry.name}
</h1>
<FittedName name={entry.name} />
{#if detail.genus}
<p class="mt-3 text-lg text-muted-foreground">{detail.genus}</p>
{/if}
Expand All @@ -55,7 +58,12 @@
{/each}
</div>

<div class="mt-8 flex flex-wrap gap-2">
<p class="mt-6 max-w-md text-pretty">
<a href="/?q={encodeURIComponent(roleQuery)}" class="font-semibold underline decoration-foreground/30 underline-offset-4 hover:decoration-foreground" title="Find every {role.label.toLowerCase()}">{role.label}.</a>
<span class="text-muted-foreground">{role.description}</span>
</p>

<div class="mt-6 flex flex-wrap gap-2">
<Button href="/compare?p={entry.slug}" variant="outline" size="sm">
<ScaleIcon />
Compare
Expand All @@ -70,20 +78,22 @@
</div>
</div>

<div class="order-1 mx-auto w-full max-w-72 md:order-2 md:max-w-none">
{#key `${entry.id}-${shiny}`}
<PokemonArt
id={entry.id}
name={shiny ? `Shiny ${entry.name}` : entry.name}
{shiny}
eager
width={512}
widths={[384, 512, 768]}
sizes="(min-width: 1024px) 26rem, (min-width: 768px) 20rem, 18rem"
class="hero-art drop-shadow-[0_16px_24px_rgb(0_0_0/0.35)]"
/>
{/key}
<div class="mt-2 flex justify-center gap-2">
<div class="order-1 mx-auto w-full max-w-80 md:order-2 md:max-w-none">
<ShapePortrait stats={entry.stats}>
{#key `${entry.id}-${shiny}`}
<PokemonArt
id={entry.id}
name={shiny ? `Shiny ${entry.name}` : entry.name}
{shiny}
eager
width={384}
widths={[256, 384, 512]}
sizes="(min-width: 1024px) 15rem, (min-width: 768px) 12rem, 11rem"
class="hero-art drop-shadow-[0_12px_18px_rgb(0_0_0/0.4)]"
/>
{/key}
</ShapePortrait>
<div class="mt-3 flex justify-center gap-2">
<Toggle bind:pressed={shiny} variant="outline" size="sm" aria-label="Show shiny coloring">
<SparklesIcon />
Shiny
Expand Down
28 changes: 28 additions & 0 deletions src/lib/components/pokemon/shape-portrait.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { SHAPE_ORDER, ringPoints } from "$lib/pokemon/shape.js";
import { STAT_KEYS, STAT_LABELS, type BaseStats } from "$lib/pokemon/types.js";
import StatShape from "./stat-shape.svelte";

let { stats, children }: { stats: BaseStats; children: Snippet } = $props();

// The shape fills the middle 82% of the box; labels sit just outside its rim.
const labels = ringPoints(0.47, 1, 0.5);
</script>

<div class="relative aspect-square">
<StatShape {stats} rings class="absolute inset-[9%] size-[82%]" />
<div class="absolute inset-[23%]">
{@render children()}
</div>
{#each SHAPE_ORDER as stat, i (stat)}
<span
class="absolute -translate-x-1/2 -translate-y-1/2 text-center leading-tight"
style:left="{labels[i].x * 100}%"
style:top="{labels[i].y * 100}%"
>
<span class="block text-xs text-muted-foreground">{STAT_LABELS[STAT_KEYS[stat]].short}</span>
<span class="block font-display text-base font-semibold sm:text-lg">{stats[stat]}</span>
</span>
{/each}
</div>
48 changes: 48 additions & 0 deletions src/lib/components/pokemon/size-comparison.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<script lang="ts">
import { formatHeight, formatWeight } from "$lib/pokemon/format.js";
import { weightComparison } from "$lib/pokemon/insights.js";
import PokemonArt from "./pokemon-art.svelte";

let { id, name, height, weight }: { id: number; name: string; height: number; weight: number } =
$props();

const PERSON = 1.7;
// Official artwork leaves a margin around the Pokémon; its body fills about this much of the frame.
const FILL = 0.82;

const metres = $derived(height / 10);
const tallest = $derived(Math.max(PERSON, metres));
const personShare = $derived((PERSON / tallest) * 100);
const artShare = $derived((metres / tallest / FILL) * 100);
const heightText = $derived(formatHeight(height));
const weightText = $derived(formatWeight(weight));
</script>

<figure class="rounded-lg border bg-card p-4 sm:p-5">
<div class="relative flex h-56 items-end justify-center gap-6 border-b border-foreground/30 sm:gap-10" aria-hidden="true">
<svg viewBox="0 0 40 170" preserveAspectRatio="xMidYMax meet" class="w-auto fill-muted-foreground/45" style:height="{personShare}%">
<circle cx="20" cy="11" r="10" />
<rect x="9" y="24" width="22" height="64" rx="6" />
<rect x="1" y="26" width="7" height="58" rx="3.5" />
<rect x="32" y="26" width="7" height="58" rx="3.5" />
<rect x="9.5" y="84" width="9.5" height="86" rx="4" />
<rect x="21" y="84" width="9.5" height="86" rx="4" />
</svg>
<div class="flex aspect-square max-w-[60%] min-w-1 items-end" style:height="{Math.min(artShare, 100 / FILL)}%">
<PokemonArt {id} name="" width={384} widths={[256, 384]} sizes="14rem" class="translate-y-[9%]" />
</div>
</div>
<figcaption class="mt-4 grid gap-1 text-sm">
<p>
<span class="font-medium tabular">{heightText.metric}</span>
<span class="text-muted-foreground tabular">{heightText.imperial}</span>
<span class="text-muted-foreground">tall, beside a {PERSON} m person.</span>
</p>
<p>
<span class="font-medium tabular">{weightText.metric}</span>
<span class="text-muted-foreground tabular">{weightText.imperial},</span>
<span class="text-muted-foreground">{weightComparison(weight).replace("About", "about")}.</span>
</p>
<span class="sr-only">{name} is {heightText.metric} tall and weighs {weightText.metric}.</span>
</figcaption>
</figure>
19 changes: 17 additions & 2 deletions src/lib/components/pokemon/stat-bars.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { MAX_BASE_STAT, statBand, statRanges } from "$lib/pokemon/stats.js";
import { STAT_KEYS, STAT_LABELS, type BaseStats } from "$lib/pokemon/types.js";

let { stats }: { stats: BaseStats } = $props();
let { stats, percentiles }: { stats: BaseStats; percentiles?: number[] } = $props();

let level = $state("100");
const ranges = $derived(statRanges(stats, Number(level)));
Expand Down Expand Up @@ -32,6 +32,7 @@
<th scope="col">Stat</th>
<th scope="col">Base</th>
<th scope="col">Distribution</th>
{#if percentiles}<th scope="col">Share of species with a lower base stat</th>{/if}
<th scope="col">Range at level {level}</th>
</tr>
</thead>
Expand All @@ -51,6 +52,12 @@
></div>
</div>
</td>
{#if percentiles}
<td class="w-24 pl-4 text-right tabular">
<span class="text-muted-foreground">beats</span>
{percentiles[i]}%
</td>
{/if}
<td class="w-24 pl-4 text-right text-muted-foreground tabular">
{ranges[i][0]}–{ranges[i][1]}
</td>
Expand All @@ -59,11 +66,19 @@
<tr>
<th scope="row" class="py-2 pr-3 text-left font-medium">Total</th>
<td class="py-2 pr-3 text-right font-semibold tabular">{total}</td>
<td colspan="2"></td>
<td></td>
{#if percentiles}
<td class="py-2 pl-4 text-right font-medium tabular">
<span class="font-normal text-muted-foreground">beats</span>
{percentiles[6]}%
</td>
{/if}
<td></td>
</tr>
</tbody>
</table>
<p class="text-xs text-muted-foreground">
{#if percentiles}“Beats” is the share of all species with a lower base stat.{/if}
Ranges assume 0 IVs, 0 EVs and a hindering nature at the low end, and 31 IVs, 252 EVs and a
helpful nature at the high end.
</p>
Expand Down
28 changes: 28 additions & 0 deletions src/lib/components/pokemon/twin-list.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<script lang="ts">
import type { DexListEntry } from "$lib/dex/list.js";
import type { StatTwin } from "$lib/pokemon/insights.js";
import PokemonArt from "./pokemon-art.svelte";
import StatShape from "./stat-shape.svelte";

let { twins, slug }: { twins: StatTwin<DexListEntry>[]; slug: string } = $props();
</script>

<ul class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each twins as { entry, gap } (entry.id)}
<li class="flex items-center gap-3 rounded-lg border bg-card p-2 pr-3" style:--tint="var(--type-{entry.types[0]})">
<span class="tint-field grid size-16 shrink-0 place-items-center rounded-md p-1">
<PokemonArt id={entry.id} name="" width={96} widths={[96, 192]} sizes="64px" />
</span>
<span class="min-w-0 flex-1">
<a href="/pokemon/{entry.slug}" class="block truncate font-medium hover:underline hover:underline-offset-4" data-sveltekit-preload-data="hover">
{entry.name}
</a>
<span class="block text-sm text-muted-foreground">
{gap === 0 ? "Identical base stats" : `About ${gap} apart per stat`}
</span>
<a href="/compare?p={slug},{entry.slug}" class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground">Compare</a>
</span>
<StatShape stats={entry.stats} class="size-12" />
</li>
{/each}
</ul>
Loading
Loading