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
204 changes: 204 additions & 0 deletions src/lib/components/atlas/atlas-chart.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
<script lang="ts">
import PokemonPeek from "$lib/components/pokemon/pokemon-peek.svelte";
import { AXIS_LABELS, axisValue, isLog, makeScale, type Axis } from "$lib/dex/atlas.js";
import type { DexListEntry } from "$lib/dex/list.js";
import { STAT_KEYS } from "$lib/pokemon/types.js";

let {
entries,
x,
y,
matches,
onopen,
}: {
entries: DexListEntry[];
x: Axis;
y: Axis;
/** Ids lit by the search; null lights everything. */
matches: Set<number> | null;
onopen: (entry: DexListEntry) => void;
} = $props();

const M = { top: 12, right: 16, bottom: 48, left: 56 };
const HIT = 24;

let width = $state(0);
let active = $state<number | null>(null);
let pointer = "mouse";
// Touch has no hover: the first tap previews a point and a second tap on it opens it.
let armed = false;

const height = $derived(Math.round(Math.min(620, Math.max(340, width * 0.62))));
const plotW = $derived(Math.max(0, width - M.left - M.right));
const plotH = $derived(height - M.top - M.bottom);

const xScale = $derived(makeScale(entries.map((e) => axisValue(e, x)), x));
const yScale = $derived(makeScale(entries.map((e) => axisValue(e, y)), y));
const bothStats = $derived(
(STAT_KEYS as readonly string[]).includes(x) && (STAT_KEYS as readonly string[]).includes(y),
);

// Base stats are whole numbers, so many Pokémon share a spot; a fixed nudge per id separates them.
const nudge = (id: number, salt: number) => (((id * salt) % 7) - 3) * 0.45;

const points = $derived(
entries.map((entry) => ({
entry,
px: M.left + xScale.at(axisValue(entry, x)) * plotW + nudge(entry.id, 37),
py: M.top + (1 - yScale.at(axisValue(entry, y))) * plotH + nudge(entry.id, 53),
lit: !matches || matches.has(entry.id),
})),
);
const dim = $derived(points.filter((p) => !p.lit));
const lit = $derived(points.filter((p) => p.lit));
const current = $derived(active !== null ? lit[active] : undefined);

// Keyboard order runs left to right, so arrow keys sweep across the chart.
const order = $derived(
lit.map((_, i) => i).sort((a, b) => lit[a].px - lit[b].px || lit[b].py - lit[a].py),
);

function format(value: number, axis: Axis) {
return isLog(axis) ? String(value) : value.toLocaleString("en");
}

function nearest(event: PointerEvent): number | null {
const box = (event.currentTarget as SVGElement).getBoundingClientRect();
const mx = event.clientX - box.left;
const my = event.clientY - box.top;
let best: number | null = null;
let distance = HIT * HIT;
lit.forEach((p, i) => {
const d = (p.px - mx) ** 2 + (p.py - my) ** 2;
if (d < distance) [best, distance] = [i, d];
});
return best;
}

function onkeydown(event: KeyboardEvent) {
if (!lit.length) return;
const position = active === null ? -1 : order.indexOf(active);
if (event.key === "ArrowRight" || event.key === "ArrowUp") {
active = order[Math.min(order.length - 1, position + 1)];
} else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
active = order[Math.max(0, position - 1)];
} else if (event.key === "Enter" && current) {
onopen(current.entry);
} else if (event.key === "Escape") {
active = null;
return;
} else return;
event.preventDefault();
}

const peekLeft = $derived(current && current.px > width - 290 ? current.px - 272 : (current?.px ?? 0) + 16);
const peekTop = $derived(current ? Math.min(Math.max(0, current.py - 100), height - 210) : 0);
</script>

<div class="relative" bind:clientWidth={width}>
{#if width}
<svg
{width}
{height}
role="listbox"
aria-roledescription="scatter plot"
aria-label="{AXIS_LABELS[y]} against {AXIS_LABELS[x]}. Use the arrow keys to move between Pokémon and Enter to open one."
aria-activedescendant={current ? `atlas-${current.entry.id}` : undefined}
tabindex="0"
class="block touch-pan-y rounded-md outline-offset-2 select-none"
onpointerdown={(event) => {
pointer = event.pointerType;
if (pointer === "mouse") return;
const hit = nearest(event);
armed = hit !== null && hit === active;
active = hit;
}}
onpointermove={(event) => {
if (event.pointerType === "mouse") active = nearest(event);
}}
onpointerleave={(event) => {
if (event.pointerType === "mouse") active = null;
}}
onclick={() => {
if (current && (pointer === "mouse" || armed)) onopen(current.entry);
}}
{onkeydown}
>
<g class="text-[11px]" aria-hidden="true">
{#each xScale.ticks as tick (tick)}
{@const tx = M.left + xScale.at(tick) * plotW}
<line x1={tx} x2={tx} y1={M.top} y2={M.top + plotH} class="stroke-border" />
<text x={tx} y={M.top + plotH + 18} text-anchor="middle" class="fill-muted-foreground tabular">{format(tick, x)}</text>
{/each}
{#each yScale.ticks as tick (tick)}
{@const ty = M.top + (1 - yScale.at(tick)) * plotH}
<line x1={M.left} x2={M.left + plotW} y1={ty} y2={ty} class="stroke-border" />
<text x={M.left - 8} y={ty + 4} text-anchor="end" class="fill-muted-foreground tabular">{format(tick, y)}</text>
{/each}
<text x={M.left + plotW / 2} y={height - 6} text-anchor="middle" class="fill-foreground text-xs font-medium">{AXIS_LABELS[x]}</text>
<text transform="translate(14 {M.top + plotH / 2}) rotate(-90)" text-anchor="middle" class="fill-foreground text-xs font-medium">{AXIS_LABELS[y]}</text>
{#if bothStats}
{@const d = Math.min(xScale.domain[1], yScale.domain[1])}
{@const ex = M.left + xScale.at(d) * plotW}
{@const ey = M.top + (1 - yScale.at(d)) * plotH}
<line x1={M.left} y1={M.top + plotH} x2={ex} y2={ey} class="stroke-muted-foreground/50" />
<text x={ex - 4} y={ey + 14} text-anchor="end" class="fill-muted-foreground">Equal</text>
{/if}
</g>

<g>
{#each dim as p (p.entry.id)}
<circle class="point" style:cx="{p.px}px" style:cy="{p.py}px" r="2.5" fill="var(--muted-foreground)" opacity="0.18" aria-hidden="true" />
{/each}
{#each lit as p (p.entry.id)}
<circle
id="atlas-{p.entry.id}"
role="option"
aria-selected={current?.entry.id === p.entry.id}
aria-label="{p.entry.name}, {AXIS_LABELS[x]} {axisValue(p.entry, x)}, {AXIS_LABELS[y]} {axisValue(p.entry, y)}"
class="point stroke-card"
style:cx="{p.px}px"
style:cy="{p.py}px"
r={matches ? 4 : 3.5}
stroke-width="1"
fill="var(--type-{p.entry.types[0]})"
/>
{/each}
{#if current}
<circle aria-hidden="true" cx={current.px} cy={current.py} r="7" fill="none" class="stroke-card" stroke-width="4" />
<circle aria-hidden="true" cx={current.px} cy={current.py} r="7" fill="none" class="stroke-foreground" stroke-width="2" />
{/if}
</g>
</svg>

{#if current}
<div
class="pointer-events-none absolute z-10 w-64 rounded-lg border bg-popover p-3 text-popover-foreground shadow-lg"
style:left="{Math.max(0, peekLeft)}px"
style:top="{peekTop}px"
aria-live="polite"
>
<PokemonPeek
entry={current.entry}
emphasis={[x, y]}
notes={[x, y]
.filter((axis) => isLog(axis as Axis))
.map((axis) => ({
label: axis === "height" ? "Height" : "Weight",
value: `${axisValue(current.entry, axis as Axis)} ${axis === "height" ? "m" : "kg"}`,
}))}
/>
</div>
{/if}
{:else}
<div class="h-[340px]"></div>
{/if}
</div>

<style>
.point {
transition:
cx 500ms cubic-bezier(0.2, 0.8, 0.2, 1),
cy 500ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
</style>
42 changes: 4 additions & 38 deletions src/lib/components/dex/dex-wall.svelte
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
<script lang="ts">
import { goto } from "$app/navigation";
import PokemonArt from "$lib/components/pokemon/pokemon-art.svelte";
import StatShape from "$lib/components/pokemon/stat-shape.svelte";
import TypeBadge from "$lib/components/pokemon/type-badge.svelte";
import { statTotal, type DexListEntry } from "$lib/dex/list.js";
import PokemonPeek from "$lib/components/pokemon/pokemon-peek.svelte";
import type { DexListEntry } from "$lib/dex/list.js";
import { heatBand, type WallColor } from "$lib/dex/wall.js";
import { dexNumber, titleCase } from "$lib/pokemon/format.js";
import { GENERATIONS } from "$lib/pokemon/generations.js";
import { STAT_KEYS, STAT_LABELS, type TypeName } from "$lib/pokemon/types.js";
import type { TypeName } from "$lib/pokemon/types.js";
import { cn } from "$lib/utils.js";

let {
Expand Down Expand Up @@ -213,46 +211,14 @@
</div>

{#if current && card}
{@const total = statTotal(current)}
<div
class="pointer-events-none absolute z-20 w-64 max-w-full rounded-lg border bg-popover p-3 text-popover-foreground shadow-lg"
style:left="{card.left}px"
style:top="{card.top}px"
style:translate={card.above ? "0 -100%" : undefined}
style:--tint="var(--type-{current.types[0]})"
aria-hidden="true"
>
<div class="flex items-center gap-3">
<span class="tint-field grid size-16 shrink-0 place-items-center rounded-md p-1">
{#key current.id}
<PokemonArt id={current.id} name="" width={96} widths={[96, 192]} sizes="64px" eager />
{/key}
</span>
<div class="min-w-0">
<p class="text-xs text-muted-foreground tabular font-condensed">{dexNumber(current.speciesId)}</p>
<p class="truncate leading-tight font-semibold">{current.name}</p>
<div class="mt-1.5 flex gap-1">
{#each current.types as type (type)}
<TypeBadge {type} size="sm" />
{/each}
</div>
</div>
</div>
<div class="mt-3 flex items-center gap-3 border-t pt-3">
<StatShape stats={current.stats} class="size-12" />
<dl class="grid flex-1 grid-cols-3 gap-x-2 gap-y-0.5 text-xs">
{#each STAT_KEYS as key, i (key)}
<div class={cn("flex justify-between gap-1", color === key && "font-semibold")}>
<dt class="text-muted-foreground">{STAT_LABELS[key].short}</dt>
<dd class="tabular">{current.stats[i]}</dd>
</div>
{/each}
</dl>
</div>
<p class={cn("mt-2 flex justify-between text-xs", color === "total" && "font-semibold")}>
<span class="text-muted-foreground">Base stat total</span>
<span class="tabular">{total}</span>
</p>
<PokemonPeek entry={current} emphasis={[color]} />
</div>
{/if}
</div>
Expand Down
63 changes: 63 additions & 0 deletions src/lib/components/pokemon/pokemon-peek.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<script lang="ts">
import { statTotal, type DexListEntry } from "$lib/dex/list.js";
import { dexNumber } from "$lib/pokemon/format.js";
import { STAT_KEYS, STAT_LABELS } from "$lib/pokemon/types.js";
import { cn } from "$lib/utils.js";
import PokemonArt from "./pokemon-art.svelte";
import StatShape from "./stat-shape.svelte";
import TypeBadge from "./type-badge.svelte";

let {
entry,
emphasis = [],
notes = [],
}: {
entry: DexListEntry;
/** Stat keys, or "total", to set in bold. */
emphasis?: string[];
/** Extra figures to list under the stats, such as height. */
notes?: { label: string; value: string }[];
} = $props();
</script>

<div style:--tint="var(--type-{entry.types[0]})">
<div class="flex items-center gap-3">
<span class="tint-field grid size-16 shrink-0 place-items-center rounded-md p-1">
{#key entry.id}
<PokemonArt id={entry.id} name="" width={96} widths={[96, 192]} sizes="64px" eager />
{/key}
</span>
<div class="min-w-0">
<p class="text-xs text-muted-foreground tabular font-condensed">{dexNumber(entry.speciesId)}</p>
<p class="truncate leading-tight font-semibold">{entry.name}</p>
<div class="mt-1.5 flex gap-1">
{#each entry.types as type (type)}
<TypeBadge {type} size="sm" />
{/each}
</div>
</div>
</div>
<div class="mt-3 flex items-center gap-3 border-t pt-3">
<StatShape stats={entry.stats} class="size-12" />
<dl class="grid flex-1 grid-cols-3 gap-x-2 gap-y-0.5 text-xs">
{#each STAT_KEYS as key, i (key)}
<div class={cn("flex justify-between gap-1", emphasis.includes(key) && "font-semibold")}>
<dt class="text-muted-foreground">{STAT_LABELS[key].short}</dt>
<dd class="tabular">{entry.stats[i]}</dd>
</div>
{/each}
</dl>
</div>
<dl class="mt-2 grid gap-0.5 text-xs">
<div class={cn("flex justify-between", emphasis.includes("total") && "font-semibold")}>
<dt class="text-muted-foreground">Base stat total</dt>
<dd class="tabular">{statTotal(entry)}</dd>
</div>
{#each notes as note (note.label)}
<div class="flex justify-between font-semibold">
<dt class="font-normal text-muted-foreground">{note.label}</dt>
<dd class="tabular">{note.value}</dd>
</div>
{/each}
</dl>
</div>
39 changes: 39 additions & 0 deletions src/lib/dex/atlas.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vite-plus/test";
import { axisValue, isAxis, makeScale } from "./atlas.js";

describe("axisValue", () => {
const entry = {
stats: [78, 84, 78, 109, 85, 100] as [number, number, number, number, number, number],
height: 17,
weight: 905,
};
it("reads stats, totals and metric sizes", () => {
expect(axisValue(entry, "special-attack")).toBe(109);
expect(axisValue(entry, "total")).toBe(534);
expect(axisValue(entry, "height")).toBe(1.7);
expect(axisValue(entry, "weight")).toBe(90.5);
});
});

describe("makeScale", () => {
it("uses round linear ticks from zero", () => {
const scale = makeScale([10, 180, 255], "attack");
expect(scale.domain).toEqual([0, 300]);
expect(scale.ticks).toEqual([0, 50, 100, 150, 200, 250, 300]);
expect(scale.at(150)).toBe(0.5);
});

it("uses powers of ten on a log scale", () => {
const scale = makeScale([0.1, 1.7, 14.5], "height");
expect(scale.ticks).toEqual([0.1, 1, 10, 20]);
expect(scale.at(20)).toBe(1);
expect(makeScale([0.1, 999.9], "weight").ticks).toEqual([0.1, 1, 10, 100, 1000]);
});
});

describe("isAxis", () => {
it("accepts known axes only", () => {
expect(isAxis("weight")).toBe(true);
expect(isAxis("cuteness")).toBe(false);
});
});
Loading
Loading